promptward

Plan 004: Add timeouts to worker and protect requests so a hang never silently blocks sends

Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving to the next step. If anything in the “STOP conditions” section occurs, stop and report — do not improvise. When done, update the status row for this plan in plans/README.md — unless a reviewer dispatched you and told you they maintain the index.

Drift check (run first): git diff --stat a6293e1..HEAD -- src/offscreen.ts src/content.ts tests/ Plans 001–003 are expected predecessors (new test file; refactored handleProtectResponse in src/content.ts). Any drift in postWorker versus the excerpt below is a STOP condition.

Status

Why this matters

When the user hits send, the content script preventDefault()s the real send and awaits a PW_PROTECT_TEXT round trip (content → background → offscreen document → Web Worker running the ONNX model). Two links in that chain can hang forever:

  1. src/offscreen.ts stores a resolver in a pending map and waits for the worker to post back. If the worker stalls (model load wedged, OOM without an error event), the promise never settles.
  2. The content script awaits chrome.runtime.sendMessage with no timeout. If the offscreen document or service worker dies mid-request, Chrome may resolve with undefined — which the code then dereferences (response.ok) — or the caller may simply never get a usable answer.

Either way the user’s message silently never sends and nothing tells them why. Version 0.9.1 (“Fix side panel hanging on model load”) shows this failure class is real. Fail-closed is correct — but it must fail closed with feedback, i.e. the existing error modal.

Current state

Commands you will need

Purpose Command Expected on success
Typecheck npx tsc -p tsconfig.json --noEmit exit 0
Tests npm test all pass

Scope

In scope:

Out of scope:

Git workflow

Steps

Step 1: Create the shared timeout helper

Create src/shared/timeout.ts:

/** Resolves with `fallback()` if `promise` hasn't settled within `ms`. */
export function withTimeout<T>(promise: Promise<T>, ms: number, fallback: () => T): Promise<T> {
  return new Promise<T>((resolve, reject) => {
    const timer = setTimeout(() => resolve(fallback()), ms);
    promise.then(
      (value) => { clearTimeout(timer); resolve(value); },
      (error: unknown) => { clearTimeout(timer); reject(error); }
    );
  });
}

Add tests/timeout.test.ts (pattern: tests/model-progress.test.ts for a plain-unit file) with fake timers: resolves before deadline → value; after deadline → fallback value; rejection propagates; timer cleared on settle.

Verify: npx vitest run tests/timeout.test.ts → 3+ tests pass.

Step 2: Bound worker requests in the offscreen document

In src/offscreen.ts, add per-type limits and apply them in postWorker:

const WORKER_TIMEOUT_MS: Record<WorkerRequest["type"], number> = {
  prewarm: 300_000,  // cold model load on slow hardware — be generous
  protect: 240_000,  // includes a possible cold start on first send
  reveal: 15_000,
  reset: 15_000
};

In postWorker, wrap the existing promise with withTimeout. The fallback must also clean up the pending entry so a late worker reply doesn’t leak:

function postWorker(request: WorkerRequest): Promise<WorkerResponse> {
  const raw = new Promise<WorkerResponse>((resolve) => {
    void logDebug({ ...existing debug call... });
    pending.set(request.id, resolve);
    getWorker().postMessage(request);
  });
  return withTimeout(raw, WORKER_TIMEOUT_MS[request.type], () => {
    pending.delete(request.id);
    void logDebug({
      debugId: "debugId" in request && request.debugId ? request.debugId : request.id,
      context: "offscreen",
      stage: "worker-timeout",
      level: "error",
      metadata: { requestType: request.type, timeoutMs: WORKER_TIMEOUT_MS[request.type] }
    });
    return { id: request.id, ok: false, error: "PromptWard's local model did not respond in time.", status: "error" };
  });
}

Verify: npx tsc -p tsconfig.json --noEmit → exit 0.

Step 3: Bound the content-side round trip and reject undefined

In src/content.ts, rewrite protectText:

const PROTECT_TIMEOUT_MS = 250_000; // slightly above the offscreen protect limit

async function protectText(text: string, debugId: string): Promise<ProtectTextResponse> {
  const request = chrome.runtime
    .sendMessage({ type: MESSAGE_TYPES.protectText, text, conversationKey: getConversationKey({ url: location.href }), url: location.href, debugId })
    .then((response: ProtectTextResponse | undefined) =>
      response ?? failedResponse("PromptWard's background service did not respond.")
    )
    .catch((error: unknown) => failedResponse(formatError(error)));
  return withTimeout(request, PROTECT_TIMEOUT_MS, () =>
    failedResponse("PromptWard timed out while redacting. Nothing was sent.")
  );
}

function failedResponse(error: string): ProtectTextResponse {
  return { ok: false, safeText: "", changed: false, placeholders: [], durationMs: 0, error };
}

The content timeout is intentionally longer than the offscreen one so the offscreen’s more specific error normally wins; the content limit only fires when the message channel itself is dead.

Verify: npx tsc -p tsconfig.json --noEmit → exit 0.

Step 4: Add flow tests

In tests/content-flow.test.ts:

  1. Undefined response fails closed with feedback: make the chrome stub resolve PW_PROTECT_TEXT with undefined → dispatch send → assert the error modal appears (no [data-action='original']), no replay click.
  2. sendMessage rejection fails closed with feedback: stub rejects with new Error("Extension context invalidated") → error modal appears, no replay, and no unhandled rejection (vitest would fail the test on one).

Do not attempt to test the 250s content timeout end-to-end with fake timers through the full flow unless it is straightforward in your harness — the withTimeout unit tests plus these two channel-failure tests cover the risk.

Verify: npx vitest run tests/content-flow.test.ts → all pass, 2 new.

Step 5: Full suite

Verify: npm test → exit 0.

Test plan

Steps 1 and 4. Unit-test the helper exhaustively with fake timers; test the channel-failure behavior through the real flow harness.

Done criteria

STOP conditions

Stop and report back if:

Maintenance notes