branch: main
panic.spec.ts
8762 bytesRaw
import { describe, test, expect } from "vitest";
import { mf, mfUrl } from "./mf";

describe("Panic Hook with WASM Reinitialization", () => {
  // These tests are explicitly run sequentially with a longer timeout
  // to ensure they fully run the reinitialization lifecycle.
  test("panic recovery tests", async () => {
    // First, detect which panic mode we're running in by checking the error message
    const detectResp = await mf.dispatchFetch(`${mfUrl}test-panic`);
    const detectText = await detectResp.text();
    
    // panic=unwind mode returns "PanicError:" in the response
    // panic=abort mode returns "Workers runtime canceled"
    const isPanicUnwind = detectText.includes("PanicError:");
    
    if (isPanicUnwind) {
      // ===== PANIC=UNWIND MODE TESTS =====
      // In this mode, panics are caught and converted to JS errors.
      // The Worker continues without reinitialization.
      
      // Test 1: Basic panic recovery - counter should NOT reset after panic
      // We use a unique durable object ID to get fresh state
      {
        const uniqueId = `UNWIND_${Date.now()}_${Math.random()}`;
        
        // Call the durable object twice to establish a counter
        const resp1 = await mf.dispatchFetch(`${mfUrl}durable/${uniqueId}`);
        const text1 = await resp1.text();
        // Extract the unstored_count from the response
        const match1 = text1.match(/unstored_count: (\d+)/);
        const count1 = match1 ? parseInt(match1[1]) : 0;

        const resp2 = await mf.dispatchFetch(`${mfUrl}durable/${uniqueId}`);
        const text2 = await resp2.text();
        const match2 = text2.match(/unstored_count: (\d+)/);
        const count2 = match2 ? parseInt(match2[1]) : 0;
        expect(count2).toBe(count1 + 1);

        // Now trigger a panic
        const panicResp = await mf.dispatchFetch(`${mfUrl}test-panic`);
        expect(panicResp.status).toBe(500);
        const panicText = await panicResp.text();
        expect(panicText).toContain("PanicError:");
        expect(panicText).toContain("Intentional panic");

        // Counter should continue from where it was (not reset)
        const resp3 = await mf.dispatchFetch(`${mfUrl}durable/${uniqueId}`);
        const text3 = await resp3.text();
        const match3 = text3.match(/unstored_count: (\d+)/);
        const count3 = match3 ? parseInt(match3[1]) : 0;
        expect(count3).toBe(count2 + 1);
      }

      // Test 2: Multiple panics don't affect subsequent requests
      {
        const uniqueId = `UNWIND2_${Date.now()}_${Math.random()}`;
        
        const resp1 = await mf.dispatchFetch(`${mfUrl}durable/${uniqueId}`);
        const match1 = (await resp1.text()).match(/unstored_count: (\d+)/);
        const count1 = match1 ? parseInt(match1[1]) : 0;

        // Trigger multiple panics
        for (let i = 0; i < 3; i++) {
          const panicResp = await mf.dispatchFetch(`${mfUrl}test-panic`);
          expect(panicResp.status).toBe(500);
          expect(await panicResp.text()).toContain("PanicError:");
        }

        // Counter should continue (not reset)
        const resp2 = await mf.dispatchFetch(`${mfUrl}durable/${uniqueId}`);
        const match2 = (await resp2.text()).match(/unstored_count: (\d+)/);
        const count2 = match2 ? parseInt(match2[1]) : 0;
        expect(count2).toBe(count1 + 1);
      }

      // Test 3: Concurrent requests with one panicking
      {
        const requests = [
          mf.dispatchFetch(`${mfUrl}test-panic`),
          mf.dispatchFetch(`${mfUrl}durable/hello`),
          mf.dispatchFetch(`${mfUrl}durable/hello`),
        ];

        const responses = await Promise.all(requests);
        
        // First should be 500 (panic), others should succeed
        expect(responses[0].status).toBe(500);
        expect(await responses[0].text()).toContain("PanicError:");
        expect(responses[1].status).toBe(200);
        expect(responses[2].status).toBe(200);
      }

    } else {
      // ===== PANIC=ABORT MODE TESTS (default) =====
      // In this mode, panics cause "Workers runtime canceled" and WASM reinitializes.

      // basic panic recovery
      {
        await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
        const resp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
        expect(await resp.text()).toContain("unstored_count: 2");

        const panicResp = await mf.dispatchFetch(`${mfUrl}test-panic`);
        expect(panicResp.status).toBe(500);

        const panicText = await panicResp.text();
        expect(panicText).toContain("Workers runtime canceled");

        const normalResp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
        expect(await normalResp.text()).toContain("unstored_count: 1");
      }

      // multiple requests after panic all succeed
      {
        const panicResp = await mf.dispatchFetch(`${mfUrl}test-panic`);
        expect(panicResp.status).toBe(500);

        const requests = [
          mf.dispatchFetch(`${mfUrl}durable/hello`),
          mf.dispatchFetch(`${mfUrl}durable/hello`),
          mf.dispatchFetch(`${mfUrl}durable/hello`),
        ];

        const responses = await Promise.all(requests);

        for (let i = 0; i < responses.length; i++) {
          const text = await responses[i].text();
          expect(responses[i].status).toBe(200);
          expect(text).toContain("Hello from my-durable-object!");
        }
      }

      // simultaneous requests during panic handling
      {
        const simultaneousRequests = [
          mf.dispatchFetch(`${mfUrl}test-panic`), // This will panic
          mf.dispatchFetch(`${mfUrl}durable/hello`), // This should succeed after reinitialization
          mf.dispatchFetch(`${mfUrl}durable/hello`),
        ];

        const responses = await Promise.all(simultaneousRequests);

        // should always have one error and one ok
        let foundErrors = 0;
        for (const response of responses) {
          if (response.status === 500) {
            expect(foundErrors).toBeLessThan(2);
            foundErrors++;
          } else {
            expect(response.status).toBe(200);
          }
        }
        expect(foundErrors).toBeGreaterThan(0);
      }

      // worker continues to function normally after multiple panics
      {
        for (let cycle = 1; cycle <= 3; cycle++) {
          const panicResp = await mf.dispatchFetch(`${mfUrl}test-panic`);
          expect(panicResp.status).toBe(500);

          const recoveryResp = await mf.dispatchFetch(`${mfUrl}durable/hello`);
          expect(recoveryResp.status).toBe(200);
        }
      }

      // explicit abort() recovery test
      {
        await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
        const resp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
        expect(await resp.text()).toContain("unstored_count:");

        const abortResp = await mf.dispatchFetch(`${mfUrl}test-abort`);
        expect(abortResp.status).toBe(500);

        const abortText = await abortResp.text();
        expect(abortText).toContain("Workers runtime canceled");

        const normalResp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
        expect(await normalResp.text()).toContain("unstored_count: 1");
      }

      // JS error recovery test
      // TODO: figure out how to achieve this one. Hard part is global error handler
      // will need to detect JS errors, not just WebAssembly.RuntimeError, which
      // may over-classify.
      // {
      //   await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
      //   const resp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
      //   expect(await resp.text()).toContain("unstored_count:");

      //   const jsErrorResp = await mf.dispatchFetch(`${mfUrl}test-js-error`);
      //   expect(jsErrorResp.status).toBe(500);

      //   const jsErrorText = await jsErrorResp.text();
      //   expect(jsErrorText).toContain("Workers runtime canceled");

      //   const normalResp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
      //   expect(await normalResp.text()).toContain("unstored_count: 1");
      // }

      // out of memory recovery test
      {
        await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
        const resp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
        expect(await resp.text()).toContain("unstored_count:");

        const oomResp = await mf.dispatchFetch(`${mfUrl}test-oom`);
        expect(oomResp.status).toBe(500);

        const oomText = await oomResp.text();
        expect(oomText).toContain("Workers runtime canceled");

        const normalResp = await mf.dispatchFetch(`${mfUrl}durable/COUNTER`);
        expect(await normalResp.text()).toContain("unstored_count: 1");
      }
    }
  }, 20_000);
});