Skip to content

FE-1218: Fix Petrinaut optimization streaming backpressure and cancellation latency#9057

Open
kube wants to merge 1 commit into
mainfrom
cf/fe-1218-petrinaut-optimization-streaming-cancellation
Open

FE-1218: Fix Petrinaut optimization streaming backpressure and cancellation latency#9057
kube wants to merge 1 commit into
mainfrom
cf/fe-1218-petrinaut-optimization-streaming-cancellation

Conversation

@kube

@kube kube commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

🌟 What is the purpose of this PR?

Hardens the Petrinaut optimization streaming and cancellation path from #9040 so a slow or disconnecting client cannot leak NodeAPI listeners, promises, or optimizer capacity, and so cancelling a busy study terminates its CLI promptly. Also restores and regression-tests propagation of the optimizer's 429 / Retry-After through NodeAPI.

🔗 Related links

🔍 What does this change?

  • NodeAPI (create-petrinaut-optimization-handler.ts): races the backpressure wait against drain, close, and the request abort signal, removing the losing listeners via an internal AbortController — no accumulated listeners, no orphaned rejected promise, no slot held past the timeouts.
  • Marks a terminal event as committed at write hand-off, so a timeout during the final backpressured write cannot append a second, contradictory terminal event; the failure-path terminal write is best-effort and never blocks teardown; heartbeats skip while the buffer needs draining.
  • Optimizer (petrinaut_client.py): close(graceful=False) signals the CLI process group immediately instead of waiting up to 5s for stdin EOF a mid-trial CLI never observes; the graceful EOF wait is reserved for studies that completed normally. An idempotent per-run cleanup shared between the stream wrapper and a StreamingResponse background task covers a client that aborts before the body is pulled.
  • Optimizer emits Retry-After on the 429 study-limit response (declared in the OpenAPI spec); the client preserves status + Retry-After, and NodeAPI passes them through.

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

This PR:

  • does not modify any publishable blocks or libraries, or modifications do not need publishing

📜 Does this require a change to the docs?

The changes in this PR:

  • are internal and do not require a docs change

🕸️ Does this require a change to the Turbo Graph?

The changes in this PR:

  • do not affect the execution graph

⚠️ Known issues

🛡 What tests cover this?

New/updated unit tests in apps/hash-api, apps/petrinaut-opt, and @local/petrinaut-optimizer-client covering: drain-first, close-first, client abort, idle timeout during backpressure, single-terminal-event, heartbeat suppression under backpressure, prompt process-group termination (fake and real subprocess), slot released exactly once (including never-started stream generators and double cancellation), and 429 / Retry-After propagation end to end.

❓ How to test this?

  1. cd apps/petrinaut-opt && uv run pytest
  2. turbo run test:unit --filter '@apps/hash-api' --filter '@local/petrinaut-optimizer-client'
  3. Confirm the streaming/cancellation and 429 suites pass.

…lation latency

NodeAPI (apps/hash-api):
- Race the backpressure wait against drain, close, and the request
  lifecycle abort signal, removing the losing listeners via an internal
  AbortController so stalled writes cannot leak listeners, strand a
  rejected promise, or hold an optimization slot past the timeouts.
- Mark terminal events as committed at write hand-off so an abort during
  the final backpressured write cannot append a second, contradictory
  terminal event.
- Write the failure-path terminal event without awaiting backpressure so
  teardown never blocks on a stalled client.
- Skip transport heartbeats while the response buffer needs draining.

Petrinaut Optimizer (apps/petrinaut-opt):
- PetrinautModel.close(graceful=False) signals the CLI process group
  immediately instead of waiting up to five seconds for stdin EOF; the
  graceful EOF wait is now reserved for studies that completed normally.
  All failure, timeout, and cancellation paths terminate promptly.
- Share one idempotent per-run cleanup between the stream wrapper and a
  StreamingResponse background task, so a client that aborts before the
  body is ever pulled cannot leak the admission slot or a live CLI.
- Close an abandoned CLI via an initializer done-callback when a second
  cancellation races the init-cancel recovery.
- Emit Retry-After on the 429 study-limit response and declare it in the
  OpenAPI spec.

Tests cover drain-first, close-first, client abort, idle timeout during
backpressure, single-terminal-event, heartbeat suppression, slot release,
prompt process-group termination, and 429/Retry-After propagation across
NodeAPI, the optimizer client, and the Python service.
@kube kube self-assigned this Jul 19, 2026
@vercel

vercel Bot commented Jul 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hash Ready Ready Preview, Comment Jul 19, 2026 1:58am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
hashdotdesign-tokens Ignored Ignored Jul 19, 2026 1:58am
petrinaut Skipped Skipped Jul 19, 2026 1:58am

@github-actions github-actions Bot added area/apps > hash* Affects HASH (a `hash-*` app) area/apps > hash-api Affects the HASH API (app) area/libs Relates to first-party libraries/crates/packages (area) type/eng > backend Owned by the @backend team area/tests New or updated tests area/apps labels Jul 19, 2026
if (response.destroyed || response.writableEnded) {
throw new Error("The optimization client disconnected");
}
if (response.write(`${JSON.stringify(event)}\n`)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Semgrep identified an issue in your code:
Detected directly writing to a Response object from user-defined input. This bypasses any HTML escaping and may expose your application to a Cross-Site-scripting (XSS) vulnerability. Instead, use 'resp.render()' to render safely escaped HTML.

Dataflow graph
flowchart LR
    classDef invis fill:white, stroke: none
    classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none

    subgraph File0["<b>apps/hash-api/src/petrinaut-optimizer/create-petrinaut-optimization-handler.ts</b>"]
        direction LR
        %% Source

        subgraph Source
            direction LR

            v0["<a href=https://github.com/hashintel/hash/blob/7e9f6ef6fa70dca4121ed983cf00994511ca5f4d/apps/hash-api/src/petrinaut-optimizer/create-petrinaut-optimization-handler.ts#L269 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 269] request.body</a>"]
        end
        %% Intermediate

        subgraph Traces0[Traces]
            direction TB

            v2["<a href=https://github.com/hashintel/hash/blob/7e9f6ef6fa70dca4121ed983cf00994511ca5f4d/apps/hash-api/src/petrinaut-optimizer/create-petrinaut-optimization-handler.ts#L269 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 269] input</a>"]

            v3["<a href=https://github.com/hashintel/hash/blob/7e9f6ef6fa70dca4121ed983cf00994511ca5f4d/apps/hash-api/src/petrinaut-optimizer/create-petrinaut-optimization-handler.ts#L283 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 283] await</a>"]

            v4["<a href=https://github.com/hashintel/hash/blob/7e9f6ef6fa70dca4121ed983cf00994511ca5f4d/apps/hash-api/src/petrinaut-optimizer/create-petrinaut-optimization-handler.ts#L283 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 283] upstreamEvents</a>"]

            v5["<a href=https://github.com/hashintel/hash/blob/7e9f6ef6fa70dca4121ed983cf00994511ca5f4d/apps/hash-api/src/petrinaut-optimizer/create-petrinaut-optimization-handler.ts#L300 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 300] forwardOptimizationEvents</a>"]

            v6["<a href=https://github.com/hashintel/hash/blob/7e9f6ef6fa70dca4121ed983cf00994511ca5f4d/apps/hash-api/src/petrinaut-optimizer/create-petrinaut-optimization-handler.ts#L199 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 199] events</a>"]

            v7["<a href=https://github.com/hashintel/hash/blob/7e9f6ef6fa70dca4121ed983cf00994511ca5f4d/apps/hash-api/src/petrinaut-optimizer/create-petrinaut-optimization-handler.ts#L204 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 204] of</a>"]

            v8["<a href=https://github.com/hashintel/hash/blob/7e9f6ef6fa70dca4121ed983cf00994511ca5f4d/apps/hash-api/src/petrinaut-optimizer/create-petrinaut-optimization-handler.ts#L204 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 204] event</a>"]

            v9["<a href=https://github.com/hashintel/hash/blob/7e9f6ef6fa70dca4121ed983cf00994511ca5f4d/apps/hash-api/src/petrinaut-optimizer/create-petrinaut-optimization-handler.ts#L205 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 205] writeOptimizationEvent</a>"]

            v10["<a href=https://github.com/hashintel/hash/blob/7e9f6ef6fa70dca4121ed983cf00994511ca5f4d/apps/hash-api/src/petrinaut-optimizer/create-petrinaut-optimization-handler.ts#L146 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 146] event</a>"]
        end
            v2 --> v3
            v3 --> v4
            v4 --> v5
            v5 --> v6
            v6 --> v7
            v7 --> v8
            v8 --> v9
            v9 --> v10
        %% Sink

        subgraph Sink
            direction LR

            v1["<a href=https://github.com/hashintel/hash/blob/7e9f6ef6fa70dca4121ed983cf00994511ca5f4d/apps/hash-api/src/petrinaut-optimizer/create-petrinaut-optimization-handler.ts#L151 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 151] `${JSON.stringify(event)}\n</a>"]
        end
    end
    %% Class Assignment
    Source:::invis
    Sink:::invis

    Traces0:::invis
    File0:::invis

    %% Connections

    Source --> Traces0
    Traces0 --> Sink


Loading

To resolve this comment:

🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by direct-response-write.

You can view more details about this finding in the Semgrep AppSec Platform.

if (response.destroyed || response.writableEnded) {
throw new Error("The optimization client disconnected");
}
if (response.write(`${JSON.stringify(event)}\n`)) {
@codecov

codecov Bot commented Jul 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.42105% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.23%. Comparing base (b00c6bd) to head (7e9f6ef).

Files with missing lines Patch % Lines
...optimizer/create-petrinaut-optimization-handler.ts 68.42% 3 Missing and 3 partials ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #9057   +/-   ##
=======================================
  Coverage   59.22%   59.23%           
=======================================
  Files        1391     1391           
  Lines      135140   135153   +13     
  Branches     6223     6226    +3     
=======================================
+ Hits        80032    80053   +21     
+ Misses      54152    54142   -10     
- Partials      956      958    +2     
Flag Coverage Δ
apps.hash-api 9.75% <68.42%> (+0.32%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cursor

cursor Bot commented Jul 19, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches concurrent streaming, admission limits, and process lifecycle for the optimization path; changes are well-tested but affect capacity and client-visible stream semantics under failure.

Overview
Hardens Petrinaut optimization streaming so slow or disconnecting clients cannot leak listeners, hold user slots, or emit duplicate terminal events.

hash-api NDJSON proxy now waits on backpressure with a race among drain, client close, and the request abort signal, cleaning listeners via an internal AbortController. Terminal complete/error events are marked as sent when the write is handed off (so idle timeout cannot add a second error), failure-path terminal writes are best-effort without blocking on drain, and transport heartbeats are skipped while writableNeedDrain is set.

petrinaut-opt adds PetrinautModel.close(graceful=False) to signal the CLI process group immediately on cancel/error (graceful EOF only after a normal study finish). Shared idempotent per-run cleanup runs from the stream finally and a StreamingResponse background task so aborted responses that never pull the body still release admission slots and close the CLI. Init cancellation gets a done-callback for abandoned CLIs; study-limit 429 responses include Retry-After: 30 (OpenAPI + client types updated).

Tests cover backpressure teardown, slot release, timeouts, single terminal events, prompt CLI shutdown, and 429 header propagation.

Reviewed by Cursor Bugbot for commit 7e9f6ef. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7e9f6ef. Configure here.

with suppress(Exception):
await asyncio.to_thread(optimizer.pn_model.close, graceful=False)
finally:
await _release_optimization_slot(app)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cleanup flag set before work finishes

High Severity

cleaned_up is set to True before CLI close and slot release finish. If the first cleanup call is cancelled after that assignment, the background retry sees the flag and returns immediately, so _release_optimization_slot never runs and an admission slot stays occupied until process restart.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7e9f6ef. Configure here.

# No-op when the stream already closed the CLI; prompt when the
# stream generators never ran at all.
with suppress(Exception):
await asyncio.to_thread(optimizer.pn_model.close, graceful=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cleanup swallows cancellation on 3.10

Medium Severity

suppress(Exception) wraps the awaited CLI close in cleanup, but on Python 3.10 asyncio.CancelledError still subclasses Exception. Cancellation during teardown is swallowed instead of propagating, which breaks disconnect/cancel handling on a supported runtime.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7e9f6ef. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/apps > hash* Affects HASH (a `hash-*` app) area/apps > hash-api Affects the HASH API (app) area/apps area/libs Relates to first-party libraries/crates/packages (area) area/tests New or updated tests type/eng > backend Owned by the @backend team

Development

Successfully merging this pull request may close these issues.

3 participants