Skip to content

feat(tasks): a linear-time regex runner for hosts that cannot interrupt a match - #1032

Merged
sroussey merged 10 commits into
mainfrom
claude/friendly-ptolemy-5fxfqz
Oct 29, 2026
Merged

sroussey merged 10 commits into
mainfrom
claude/friendly-ptolemy-5fxfqz

Conversation

@sroussey

@sroussey sroussey commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • createLinearRegexRunnerFactory(engine) in @workglow/tasks (packages/tasks/src/util/LinearRegexRunner.ts). It is a RegexRunnerFactory over an RE2-family engine. A host installs it through the existing registerRegexRunnerFactory seam.
  • SafeFetch.ts header: records why a deployed Worker keeps the browser default.
  • TFMP_GenaiRuntime.ts comment: 0.10.30 still lacks clearCancelSignals in its bundle, so the fallback comment names both versions. The WASM pin fix this branch carried is superseded by main's, which takes the versions from the installed SDKs at build time.
  • The HF Inference conformance test is pinned to Llama 3.1 8B on deepinfra, ported from libs#1031.
  • a2a's typecheck budget is re-baselined; the measurements are in the commit message.

Why

The Node entry installs a bounded regex runner that uses node:vm timeouts. A Cloudflare Worker or Durable Object has no vm, and it resolves the browser build of every @workglow/* package, where the regex runner is unbounded. One catastrophic pattern in RegexTask would stall every object sharing the isolate until the platform kills the invocation. The workglow.com server tier is moving to Durable Objects, so it needs a bound that doesn't depend on interrupting a match.

How it behaves

  • The host supplies the engine. re2js's RE2JS class satisfies the interface as is, so @workglow/tasks takes no new dependency. re2js is pinned at 2.8.6 as a test-only dependency.
  • One match is linear in the input. Catastrophic patterns such as (a|a)*$ and ^(a?b?)*$ finish at once on 50,000-character inputs.
  • The input is capped, and a global scan is capped lower. Lengths are in UTF-16 code units; a longer input is refused with a TaskInvalidInputError.
    • A global scan (execAll with the g flag) is limited to 16,384 code units (LINEAR_REGEX_MAX_GLOBAL_INPUT_LENGTH). A scan can grow with the square of the input: for /a[^z]*z|a/g over a run of a, each match reads to the end. Under Node that pattern takes 7.7 s at 16k and 30.8 s at 32k, and did not finish in 300 s at 64k. Ordinary patterns scan in linear time.
    • One match (exec, or execAll without g) is limited to 65,536 code units (LINEAR_REGEX_MAX_INPUT_LENGTH), since a single match is linear.
  • What it refuses: backreferences, lookahead, lookbehind, empty classes, and the y and v flags. Each is refused with a TaskInvalidInputError rather than matched without a bound.
    • \k counts as a backreference only where JavaScript reads it as one, in a pattern with u or a named group. re2js would otherwise silently turn it into literal text. Elsewhere \k is the letter k.
  • What it rewrites to JavaScript's meaning, where RE2 reads the same syntax differently:
    • \s and \S, ., multiline ^, [^] and [\b];
    • a [ inside a class, which RE2 would read as a POSIX class;
    • without u, the escapes JavaScript reads as plain letters (\A, \z, \a, \Q, \E, \p, \P, \k, \x{, \u{) and \c before a non-letter;
    • a - beside an expanded \s or \S.
  • What still differs from JavaScript (listed in the module header):
    • a multiline $ is refused over input with \r, U+2028 or U+2029 line endings, including Windows CRLF text;
    • without u, an astral character counts as one character;
    • quantified groups keep a capture from an earlier iteration, and an iteration that matches empty does not end the loop;
    • under i without u, case folding is Unicode folding (/s/i also matches ſ).
  • Compiled programs are cached: the last 64, keyed by source and flags, so a RegexTask fan-out over one pattern compiles it once.

On SafeFetch, no new implementation is needed when deployed. workerd's outbound network refuses private, loopback and link-local peers at connect(), after DNS. This was measured with a hostname mapped to 127.0.0.1, plus 127.0.0.1, 169.254.169.254 and 10.0.0.1: all four were refused. A deployed Worker on Cloudflare also refused nip.io hostnames resolving to private addresses. Under Miniflare (wrangler dev), private addresses are allowed, so only the static check applies there.

Tests

packages/test/src/test/task/LinearRegexRunner.test.ts, 57 tests:

  • results compared with the native engine over a corpus of patterns and inputs, including the cases that diverged before the review fixes;
  • every refusal, including inputs one past each cap;
  • the source rewrite, checked directly;
  • three catastrophic patterns through exec;
  • RegexTask running with the runner installed.

It passes under both Vitest and bun test. After the merge of main (61dd0f0), Vitest passes on it, RegexTask.test.ts and TFMP_GenaiHelpers.test.ts (98 tests across the three files). oxlint --type-aware and oxfmt --check are clean.

The CodeQL alerts on the deliberate catastrophic test fixtures were dismissed by the owner.

Related

  • Consumer: workglow-dev/builder#535. The Cloudflare server tier registers this runner once a release carries it; the published 0.6.14 does not.
  • Design: workglow-dev/prd#116, docs/superpowers/specs/2026-10-18-c01-amendment-cloudflare-server-tier-design.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LVgexVV91n4PaqB1i5Raag

…pt a match

A Worker or Durable Object has no vm timeout, so the bounded runner the
Node entry installs cannot run there, and the browser default matches
unbounded: one catastrophic pattern stalls every object sharing the
isolate until the platform kills the invocation.

createLinearRegexRunnerFactory takes an RE2-family engine from the host
(re2js's RE2JS satisfies it as is, so @workglow/tasks gains no
dependency) and registers through the existing registerRegexRunnerFactory
seam. Backreferences, lookaround and the y and v flags are refused rather
than matched without a bound. Where RE2 reads JavaScript syntax
differently the source is rewritten to JavaScript's meaning: \s and \S,
'.', a multiline ^, [^] and [\b]. A multiline $ is refused over input
with \r line endings, which RE2 cannot match faithfully. The tests check
it against the native engine over a corpus; without the rewrite eight of
them fail.

SafeFetch's header now records why a workerd host keeps the browser
default: workerd refuses private peers at connect(), after DNS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgexVV91n4PaqB1i5Raag

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 7/7 files

Approve — reviewed files look good at 6148af8.

✅ All clear — nothing to fix.


Mergestorm: 30 free reviews a month

…egex fixtures as strings

typecheck-budget: packages/a2a went from 18,951 to 25,707 instantiations.
The cause is one more export from @workglow/tasks' barrel, not anything
a2a uses: a module exporting a single constant, a named re-export and a
star export at the end of the barrel each give the same 25,707, and one
further trivial export gives 25,346. a2a imports three names from that
package. The count follows the barrel's shape, so the entry is set to the
measured value; the other 43 are unchanged.

CodeQL flagged the catastrophic patterns in LinearRegexRunner.test.ts as
inefficient regexes. They are deliberate fixtures that only the linear
engine ever sees. They are now strings compiled per test, as RegexTask's
own catastrophic-pattern test keeps its pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgexVV91n4PaqB1i5Raag

Copy link
Copy Markdown
Collaborator Author

test-vitest-ai-provider-api is failing for reasons outside this PR. This PR touches only @workglow/tasks' regex runner, its test, and the budget file. No provider code changed.

What fails. The HF Inference conformance test (packages/test/src/test/ai-provider-api/HFI_Generic.integration.test.ts) gets back "We have not been able to find inference provider information for model meta-llama/Llama-3.3-70B-Instruct".

Why. The test pins the router provider together, and Hugging Face no longer maps that model to Together. Its current mapping (/api/models/meta-llama/Llama-3.3-70B-Instruct?expand[]=inferenceProviderMapping) lists novita, featherless-ai, scaleway and ovhcloud as live, and groq in error.

Proposed patch (not applied here, so this PR doesn't widen):

-        provider_config: { model_name: "meta-llama/Llama-3.3-70B-Instruct", provider: "together" },
+        provider_config: { model_name: "meta-llama/Llama-3.3-70B-Instruct", provider: "novita" },

A second blocker may sit behind it. With novita pinned, a local run using the repo's test credentials reached the provider, then every call failed with "You have no remaining credits", embeddings included. If CI uses the same HF_TOKEN, the job stays red after the pin until that account has credits. I couldn't confirm the patch end to end for that reason. I haven't re-run the job, because the mapping failure is deterministic.

Pushed in d6e5461 for the other two red checks:

  • typecheck-budget: a2a's entry is re-baselined. The commit message has the measurements showing the count follows @workglow/tasks' barrel shape, not this code.
  • CodeQL: the regex fixtures are now strings. Both threads are answered and left open.

Generated by Claude Code

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 8/8 files

Approve — reviewed files look good at d6e5461.

✅ All clear — nothing to fix.


Mergestorm: 30 free reviews a month

…n deepinfra

Ported from libs#1031 (6cf4074), as that change leaves the file. The suite
pinned Llama 3.3 70B to the together router, which no longer serves it,
so every tool-call run failed. The net change from main is the model and
provider swap; #1031's interim skip is not carried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgexVV91n4PaqB1i5Raag

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 9/9 files

Approve — reviewed files look good at 5791139.

✅ All clear — nothing to fix.


Mergestorm: 30 free reviews a month

Copy link
Copy Markdown
Collaborator Author

test-vitest-ai-provider-api on 5791139: the Hugging Face failure is fixed. The pin to Llama 3.1 8B on deepinfra is ported from libs#1031 (6cf4074a0), and the HF Inference conformance suite now passes.

The job is still red on Gemini alone: 3 tests in Gemini_Generic.integration.test.ts and Gemini_ImageGeneration.integration.test.ts. Both fail with 402 "Your prepayment credits are depleted" from AI Studio. That's the Gemini test account's billing, not code. libs#1031 fails the same way, so there is no code fix to port, and a re-run will fail until the credits are topped up. Everything else is green (126 passed).


Generated by Claude Code

Each fix makes the linear runner match what the native engine does:
- A '[' inside a character class is escaped, so RE2 no longer reads
  [:name:] as a POSIX class where JavaScript reads literal characters.
- Without the u flag, \A \z \a \Q \E \p \P \x{ \u{ become the plain
  letter (keeping a following {n} a quantifier), and \c before a
  non-letter becomes a literal backslash, or a control character inside
  a class before a digit or '_', as JavaScript reads them.
- A '-' beside an expanded \s or \S in a class is escaped, so
  [\d\s-()] compiles and [\s-\uffff] matches what it does natively.
- The factory keeps the last 64 compiled programs, keyed by source and
  flags, so a RegexTask fan-out over one pattern compiles it once. A
  refused pattern is never cached.
- LinearRegexSource is renamed ILinearRegexSource, per the I-prefix rule
  for public interfaces, and toLinearRegexSource gets a direct test.

The module header now states what it does not fix: execAll can be
quadratic for patterns like /a[^z]*z|a/g, quantified groups keep stale
captures and do not stop on an empty iteration, and case folding under
i without u is Unicode folding. SafeFetch's note says the connect-time
refusal holds when deployed; under Miniflare only the static check
applies. 15 new corpus cases cover the fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgexVV91n4PaqB1i5Raag

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 9/9 files

Request changes — found 2 issue(s) at c4be555.

Actionable comments posted: 2

🤖 Prompt for AI agents
Pull request workglow-dev/libs#1032: https://github.com/workglow-dev/libs/pull/1032 (head branch `claude/friendly-ptolemy-5fxfqz`)

Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.

Findings to address:
1. In `@packages/tasks/src/util/LinearRegexRunner.ts` (line 222, Blocker):
   `out += ch` passes a non-multiline `$` through unchanged, but RE2 treats it as end-of-text while JavaScript also lets it match immediately before a final line terminator. For example, `new RegExp("foo$")` matches `"foo\n"` natively, while this runner does not; `RegexTask` therefore reports a false negative for that input.

2. In `@packages/tasks/src/util/LinearRegexRunner.ts` (line 156, Important):
   `out += "\\\\c"` makes a legacy `\c` followed by a non-letter match a literal backslash plus `c`. JavaScript treats that as an identity escape for `c` outside a class, so `new RegExp("\\c1", "g")` on the input `"\\c1"` matches `"c1"` natively, but this runner matches `"\\c1"`. This makes the linear runner return different `RegexTask.matches` for the pattern/input pair.

Mergestorm: 30 free reviews a month

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 8/8 files

Approve — reviewed files look good at dc9e50a.

✅ All clear — nothing to fix.


Mergestorm: 30 free reviews a month

Copy link
Copy Markdown
Collaborator Author

test-vitest-unit (3/4) on dc9e50a is failing on main too, not because of this PR. main (3a24ff3, release 0.6.14) fails the same job with the same assertion.

What fails. TFMP_GenaiHelpers.test.ts > pinned WASM versions checks that the WASM version constants in providers/tf-mediapipe/src/ai/common/TFMP_Runtime.ts match the installed @mediapipe/tasks-* packages. main's "chore: update deps" moved the installed packages to tasks-vision/text/audio 1.1.1 and tasks-genai 0.10.30, but the constants still say 1.1.0 and 0.10.29.

Proposed patch. No fix exists on any branch yet, so I haven't applied one here.

-export const TFMP_VISION_WASM_VERSION = "1.1.0";
-export const TFMP_TEXT_WASM_VERSION = "1.1.0";
-export const TFMP_AUDIO_WASM_VERSION = "1.1.0";
-export const TFMP_GENAI_WASM_VERSION = "0.10.29";
+export const TFMP_VISION_WASM_VERSION = "1.1.1";
+export const TFMP_TEXT_WASM_VERSION = "1.1.1";
+export const TFMP_AUDIO_WASM_VERSION = "1.1.1";
+export const TFMP_GENAI_WASM_VERSION = "0.10.30";

One thing to check with it: TFMP_GenaiRuntime.ts:232 carries a workaround for 0.10.29 shipping without clearCancelSignals, which may no longer apply on 0.10.30. That belongs in the fix on main. The alternative is pinning the deps back to 1.1.0 and 0.10.29. The rest of this PR's checks on dc9e50a are still running.


Generated by Claude Code

The catalog moved to @mediapipe/tasks-{vision,text,audio} 1.1.1 and
tasks-genai 0.10.30, but the CDN fileset pins stayed at 1.1.0 / 0.10.29,
so the JS SDK loaded a WASM build from the release before it. Every file
each installed package ships under wasm/ is served by jsdelivr at the new
version. 0.10.30 still does not export clearCancelSignals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgexVV91n4PaqB1i5Raag
A global scan can be quadratic in the input (`/a[^z]*z|a/g` over a run of
`a`), and a Worker has no clock to stop one with, so a runner from
createLinearRegexRunnerFactory refuses an input longer than
LINEAR_REGEX_MAX_INPUT_LENGTH (65,536 UTF-16 code units) with a
TaskInvalidInputError, for one match and a global scan alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgexVV91n4PaqB1i5Raag

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 11/11 files

Comment — found 1 issue(s) at 1be9094.

Actionable comment posted: 1

🤖 Prompt for AI agents
Pull request workglow-dev/libs#1032: https://github.com/workglow-dev/libs/pull/1032 (head branch `claude/friendly-ptolemy-5fxfqz`)

Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.

Findings to address:
1. In `@packages/tasks/src/util/LinearRegexRunner.ts` (line 144, Important):
   `\k` is not always a named backreference: without `u` and without named capture groups, JavaScript treats it as an identity escape matching `k` (and inside a character class it is literal as well). Thus valid patterns such as `new RegExp("\\k")` and `new RegExp("[\\k]")` are rejected by this runner even though they need no unsupported feature; distinguish the `\k<name>` backreference form from these identity escapes before refusing.

Mergestorm: 30 free reviews a month

…roup

Without `u` and without a named group, JavaScript reads `\k` as an
identity escape, in a class or out; only where a named group or `u` makes
it a backreference does the linear runner have to refuse it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgexVV91n4PaqB1i5Raag

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 10/10 files

Comment — found 1 issue(s) at bc02aed.

Actionable comment posted: 1

🤖 Prompt for AI agents
Pull request workglow-dev/libs#1032: https://github.com/workglow-dev/libs/pull/1032 (head branch `claude/friendly-ptolemy-5fxfqz`)

Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.

Findings to address:
1. In `@packages/tasks/src/util/LinearRegexRunner.ts` (line 166, Important):
   `out += "\\\\c"` is also used when `inClass` is true and `\c` is followed by punctuation (for example, `new RegExp("[\\c!]")`). In a legacy JavaScript character class, that `\c` is an identity escape for `c`, so the native class matches `c` or `!`, not a backslash. This rewrite adds `\\` to the class, causing the linear runner (and RegexTask when installed) to report an extra match for `"\\"`.

Mergestorm: 30 free reviews a month

A global scan can be quadratic in its input, so 64 Ki still let one
pattern run for minutes: `/a[^z]*z|a/g` over a run of `a` took 7.7 s at
16 Ki and 30.8 s at 32 Ki under Node, and did not finish in 300 s at
64 Ki. A global scan is now refused past
LINEAR_REGEX_MAX_GLOBAL_INPUT_LENGTH (16,384). One match is linear in its
input and keeps LINEAR_REGEX_MAX_INPUT_LENGTH (65,536).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgexVV91n4PaqB1i5Raag

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 10/10 files

Approve — reviewed files look good at b2fbc37.

✅ All clear — nothing to fix.


Mergestorm: 30 free reviews a month

…my-5fxfqz

# Conflicts:
#	providers/tf-mediapipe/src/ai/common/TFMP_Runtime.ts

@mergestorm-vortex mergestorm-vortex 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.

Auto land this PR in Mergestorm

Review progress ██████████ 9/9 files

Approve — reviewed files look good at c789048.

✅ All clear — nothing to fix.


Mergestorm: 30 free reviews a month

@sroussey
sroussey merged commit 39a5338 into main Oct 29, 2026
19 checks passed
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.

3 participants