fix(tasks): correctness fixes in the file and fetch tasks - #844
Merged
sroussey merged 5 commits intoAug 20, 2026
Conversation
Two matches closer together than `beforeContext` re-emitted the lines
between them. On a non-matching line the scan dropped `currentGroup`,
which blinded `emitLine`'s de-duplication when the before-buffer was
replayed for the next match: ["match one", "filler", "match two"] with
beforeContext 2 produced groups {1..1} and {1..3} with line 1 in both,
carrying match: true in one and match: false in the other.
Dropping `currentGroup` was redundant to begin with — `entry.line <=
currentGroup.endLine + 1` already opens a new group across a gap — and
retaining it is what lets the de-duplication see the replayed lines.
The de-duplication now also runs BEFORE the output caps. A line already
in the group emits nothing, so charging it against `maxOutputLines` (or
letting the cap refuse it) dropped real matches the caps had room for:
the same input with maxOutputLines 3 lost the second match and reported
truncated: true, for three distinct lines.
The group shape changes for a multi-match-within-context run: two
overlapping groups become one contiguous group, matching GNU grep's
-B/-A block semantics.
Co-Authored-By: Claude <noreply@anthropic.com>
`expandReplacement` substituted "" for every `$<name>` run, so sedding with a replacement like `bar $<id>` against a pattern carrying no named group silently DELETED the caller's text. That is not what the engine does: with no named captures the `$<` is not a substitution at all, so `"abc".replace(/b/, "[$<x>]")` yields `"a[$<x>]c"`. The run is now copied through verbatim in that case, by emitting `$` without advancing past the closing `>` and letting the loop's literal path reproduce the rest one character at a time. An unknown name in a pattern that DOES have named groups still expands to "", which is also what the engine does. Co-Authored-By: Claude <noreply@anthropic.com>
Reading the message out of a non-2xx response used `await response.text()`, which materializes the WHOLE body before slicing it down to 4 KB. An origin answering a 500 with a multi-gigabyte body was therefore held in memory in full to produce a few kilobytes of message. The read is now bounded on both sides of the decode: a hard 16 KiB wire ceiling and the existing character cap, whichever comes first. The reader is cancelled in a `finally`, which releases the socket back to the pool whether the read stopped early or reached EOF. The 4096 constant is renamed to HTTP_ERROR_BODY_MAX_CHARS, since it was always applied to a UTF-16 code-unit count rather than to bytes. The `discardBody` call that followed is dropped — the reader's cancel already covers it — with a comment saying so, so a later reader does not re-add it. Co-Authored-By: Claude <noreply@anthropic.com>
…ob/arraybuffer
A HEAD response carries no representation body, so the HEAD branch
finishes with metadata alone regardless of `response_type`. Combined
with the derived types that shipped in 0.3.47 alongside HEAD, that meant
`{ method: "HEAD", response_type: "json" }` completed SUCCESSFULLY with
`json` undefined — the same silent success with no value that making
`response_type` required exists to prevent, reached from the other
direction.
Only `response_type: "stream"` is now accepted with HEAD; the four
derived types are rejected. The job layer fails before the request is
issued, so the bad combination spends no network call, and
INVALID_RESPONSE_TYPE is not retryable, so it burns no retry budget.
`validateInput` rejects the same pairing at the task layer, before
anything is enqueued.
BEHAVIOUR CHANGE: `{ method: "HEAD", response_type: "text" | "json" |
"blob" | "arraybuffer" }` previously resolved with metadata and an
undefined value port; it now fails with INVALID_RESPONSE_TYPE. Use
`response_type: "stream"` and read `metadata`, or use GET.
Co-Authored-By: Claude <noreply@anthropic.com>
…-fixes main's 41e0233 fixes finding F3 (readHttpErrorBody buffering the whole error body) independently, and better: it bounds the read the same way but adds HTTP_ERROR_BODY_READ_MS, a 100ms read budget that closes the residual hang this branch's version explicitly did not cover — an origin that returns a small error body and then trickles forever. Resolution: - readHttpErrorBody, HTTP_ERROR_BODY_MAX_BYTES and HTTP_ERROR_BODY_READ_MS are taken from main verbatim. This branch's competing rewrite, its HTTP_ERROR_BODY_MAX_CHARS rename and its 16 KiB wire ceiling are dropped. - The F8 work (assertMethodAllowsResponseType, its call in executeStream, and the validateInput override) is kept: it is unrelated to F3 and main did not touch it. - The discardBody(response) call after buildHttpError stays removed. buildHttpError always calls readHttpErrorBody, which cancels its reader in a finally, so the socket is already released. - The test "an HTTP error cancels the response body instead of abandoning it" is restored to main's version. This branch had enlarged its chunk to 64 KiB to make the cancel observable under its own implementation; under main's the 3-byte body never closes, the 100ms read budget fires the cancel, and the test passes unchanged (103ms). - The "reads only a bounded prefix of a huge error body" test is kept: main has no test pinning the byte ceiling, and it passes against main's implementation. Co-Authored-By: Claude <noreply@anthropic.com>
Coverage Report
File CoverageNo changed files found. |
sroussey
deleted the
claude/branch-security-review-xs0tph-libs-task-fixes
branch
August 20, 2026 02:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three independent correctness fixes in
@workglow/tasks. They share no files; each is one commit with its own test, and every test was verified to fail before its fix and pass after.1.
grepLinesre-emitted already-emitted lines (FileGrepTask.ts)Two matches closer together than
beforeContextreplayed the lines between them. On a non-matching line the scan droppedcurrentGroup, which blindedemitLine's de-duplication when the before-buffer was replayed for the next match.Extracted and executed against
["match one", "filler", "match two"]withbeforeContext: 2, pattern/match/:Before
Line 1 appears twice, in overlapping ranges, with contradictory
matchflags. WithmaxOutputLines: 3— enough for the three distinct lines — the replayed copies consumed the budget and the second real match was dropped entirely, reported astruncated: true:After (both cases identical, and
maxOutputLines: 3now fits)The fix has two halves, both needed:
currentGroupon a non-match was redundant —entry.line <= currentGroup.endLine + 1already opens a new group across a gap — and retaining it is what lets the de-duplication see the replayed lines;maxOutputLines/maxOutputChars, nor refused by them.Group shape changes for a multi-match-within-context run: two overlapping groups become one contiguous group, which is GNU
grep -B/-Ablock semantics. No existing test asserted the old shape. Non-context andonlyMatchingruns are byte-identical (re-run as controls).2.
expandReplacementdeleted$<name>(FileSedTask.ts)With no named captures the
$<is not a substitution —"abc".replace(/b/, "[$<x>]")yields"a[$<x>]c"— but the expander substituted""unconditionally, so sedding with a replacement likebar $<x>against a pattern carrying no named group silently deleted the caller's text. The run is now copied through verbatim in that case. An unknown name in a pattern that does have named groups still expands to"", matching the engine.3. HEAD + a derived
response_typesilently succeeded with no value (FetchUrlTask.ts)A HEAD response carries no representation body, so the HEAD branch finishes with metadata alone regardless of
response_type.assertResponseTypeaccepts"json", so{ method: "HEAD", response_type: "json" }completed successfully withjsonundefined — the same silent success with no value that makingresponse_typerequired exists to prevent, reached from the other direction.assertMethodAllowsResponseTypenow rejects the pairing before the request is issued (so it spends no network call), andvalidateInputrejects it at the task layer before anything is enqueued.INVALID_RESPONSE_TYPEis outsideFETCH_URL_RETRYABLE_ERROR_CODES, so it burns no retry budget.Merge resolution
mainadvanced to41e0233("feat(tasks): improve error body reading in FetchUrlTask") while this PR was open, which fixes the samereadHttpErrorBodydefect this PR's fourth finding addressed, in the same function.origin/mainis merged into this branch (a merge commit, not a rebase — the branch is pushed and may be referenced).main's implementation wins, taken verbatim. It bounds the read the same way this PR did, but additionally addsHTTP_ERROR_BODY_READ_MS = 100— a read budget that closes a residual hang this PR's author explicitly flagged as not covered by their version: an origin that returns a small error body and then trickles forever. The two implementations were not merged and this PR's version was not ported over it.Dropped from this PR:
readHttpErrorBodyrewrite;HTTP_ERROR_BODY_MAX_BYTES→HTTP_ERROR_BODY_MAX_CHARSrename;mainbounds atHTTP_ERROR_BODY_MAX_BYTES = 4096);Kept:
assertMethodAllowsResponseType, its call inexecuteStreambeforeissueRequest, and thevalidateInputoverride). It is in the same file but unrelated to the error-body read, andmaindid not touch it.discardBody(response)removal afterbuildHttpError, which is still correct undermain's version and still carries a comment saying why.buildHttpErrorunconditionally callsreadHttpErrorBody, which cancels its reader in afinally— on the byte ceiling, on the read budget, or at EOF — so the socket is already released. With a body there is nothing left to cancel and the stream is still reader-locked, so a secondcancel()only raises aTypeErrorfordiscardBodyto swallow; with no body it was a no-op to begin with. The two otherdiscardBodycall sites (the 304 branch and the HEAD-OK branch) never reachreadHttpErrorBodyand are untouched.reads only a bounded prefix of a huge error bodytest, becausemainhas no test pinning the byte ceiling. Honest caveat: unlike the other tests here it does not go red againstmain's source — it asserts a propertymain's implementation also has, so it is additive regression coverage rather than proof of a change in this PR. It would still catch a regression toawait response.text()(which pulls all 65 chunks).Restored to
main's version: the testan HTTP error cancels the response body instead of abandoning it. This PR had enlarged its chunk from 3 bytes to 64 KiB to make the cancel observable under its own implementation; undermain's that edit is unnecessary, so it was reverted rather than kept. The 3-byte body never closes, the 100 ms read budget fires the cancel, and the test passes unchanged — in 103 ms, which is the budget doing exactly that.packages/test/src/test/task/FetchTask.test.tsis now purely additive againstmain(80 insertions, 0 deletions).Net effect on
FetchUrlTask.tsversusmain: 63 insertions, 1 deletion — the F8 additions plus the single removeddiscardBodyline. ThereadHttpErrorBody/HTTP_ERROR_BODY_MAX_BYTES/HTTP_ERROR_BODY_READ_MSregion is byte-identical tomain(verified by SHA-256).Tests
Five new tests, each verified red before its fix and green after — re-verified after the merge by reverting each source hunk alone against the merged tree:
$<name>literal when the pattern has no named groupsPlus two pins that are green both ways by design:
(?<foo>…)→[$<foo>]=[foo]and[$<bar>]=[], and the bounded-prefix error-body test described under Merge resolution.Suites run on the final merged state:
Environment note: this container has Node 22 (
/opt/node22), not the Node 24+ the repo asks for. No native SQLite-backed suites are in the affected set, and the wholepackages/test/src/test/task/directory passes.Generated by Claude Code