|
1 | 1 | // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | | -import { describe, it, expect } from 'vitest'; |
| 3 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
4 | 4 | import { QuickJSScriptRunner, SandboxError } from './quickjs-runner.js'; |
5 | 5 | import type { ScriptContext, ScriptRunOptions } from './script-runner.js'; |
6 | 6 |
|
@@ -370,9 +370,13 @@ describe('QuickJSScriptRunner — nested sandbox re-entrancy (#1867)', () => { |
370 | 370 | // the 250ms hook default and killed mid-flight. |
371 | 371 | // --------------------------------------------------------------------------- |
372 | 372 | describe('QuickJSScriptRunner — timeout resolution honors body.timeoutMs (#1867)', () => { |
373 | | - // Stock engine defaults (250ms hooks) — these tests assert the default budget |
374 | | - // itself, so they must NOT use the generous shared `runner` above. |
375 | | - const defaultRunner = new QuickJSScriptRunner(); |
| 373 | + // Pin the hook default to 250ms EXPLICITLY (not the bare stock constructor) so |
| 374 | + // these assertions on the effective budget stay independent of any ambient |
| 375 | + // `OS_SANDBOX_HOOK_TIMEOUT_MS` the environment/CI may set (#3259). They assert |
| 376 | + // the resolution logic — body.timeoutMs vs the runner default — which is |
| 377 | + // identical whether that default came from the built-in constant or an |
| 378 | + // explicit option. (A dedicated suite below covers the env override itself.) |
| 379 | + const defaultRunner = new QuickJSScriptRunner({ hookTimeoutMs: 250 }); |
376 | 380 |
|
377 | 381 | it('honors a hook body timeoutMs above the 250ms hook default', async () => { |
378 | 382 | // Host call settles at ~600ms — comfortably past the old 250ms hook cap but |
@@ -423,6 +427,60 @@ describe('QuickJSScriptRunner — timeout resolution honors body.timeoutMs (#186 |
423 | 427 | }, 10000); |
424 | 428 | }); |
425 | 429 |
|
| 430 | +// --------------------------------------------------------------------------- |
| 431 | +// Env-overridable timeout defaults (#3259). Every invocation compiles a fresh |
| 432 | +// WASM module, and a nested hook compiles another inside the parent's budget, so |
| 433 | +// on a loaded/slow host (an oversubscribed CI runner) the 250ms hook default can |
| 434 | +// trip even while the VM is making progress. `OS_SANDBOX_HOOK_TIMEOUT_MS` lets an |
| 435 | +// operator raise the FALLBACK default without a code change; an explicit |
| 436 | +// constructor option still wins over the env, and a body's own timeoutMs still |
| 437 | +// wins over the resolved default. Each test constructs its runner AFTER setting |
| 438 | +// the env (the constructor reads it once), and the env is saved/restored so a |
| 439 | +// CI-wide value doesn't leak in or out. |
| 440 | +// --------------------------------------------------------------------------- |
| 441 | +describe('QuickJSScriptRunner — env-overridable timeout defaults (#3259)', () => { |
| 442 | + const HOOK_ENV = 'OS_SANDBOX_HOOK_TIMEOUT_MS'; |
| 443 | + let saved: string | undefined; |
| 444 | + beforeEach(() => { |
| 445 | + saved = process.env[HOOK_ENV]; |
| 446 | + delete process.env[HOOK_ENV]; |
| 447 | + }); |
| 448 | + afterEach(() => { |
| 449 | + if (saved === undefined) delete process.env[HOOK_ENV]; |
| 450 | + else process.env[HOOK_ENV] = saved; |
| 451 | + }); |
| 452 | + |
| 453 | + // A never-settling host call forces the deadline path; the SandboxError embeds |
| 454 | + // the RESOLVED budget, so asserting on the message proves which timeout applied |
| 455 | + // without a flaky wall-clock measurement. |
| 456 | + const neverSettles = { object: () => ({ update: () => new Promise<never>(() => {}) }) }; |
| 457 | + const runHook = (r: QuickJSScriptRunner) => |
| 458 | + r.runScript( |
| 459 | + { language: 'js', source: "return await ctx.api.object('x').update({});", capabilities: ['api.write'] }, |
| 460 | + ctx({ api: neverSettles }), |
| 461 | + hookOpts, |
| 462 | + ); |
| 463 | + |
| 464 | + it('falls back to the built-in 250ms hook default when the env var is unset', async () => { |
| 465 | + await expect(runHook(new QuickJSScriptRunner())).rejects.toThrow(/timeout of 250ms/); |
| 466 | + }, 10000); |
| 467 | + |
| 468 | + it('uses OS_SANDBOX_HOOK_TIMEOUT_MS as the default when set', async () => { |
| 469 | + process.env[HOOK_ENV] = '150'; |
| 470 | + await expect(runHook(new QuickJSScriptRunner())).rejects.toThrow(/timeout of 150ms/); |
| 471 | + }, 10000); |
| 472 | + |
| 473 | + it('lets an explicit constructor option win over the env var', async () => { |
| 474 | + process.env[HOOK_ENV] = '150'; |
| 475 | + await expect(runHook(new QuickJSScriptRunner({ hookTimeoutMs: 50 }))).rejects.toThrow(/timeout of 50ms/); |
| 476 | + }, 10000); |
| 477 | + |
| 478 | + it('ignores a non-numeric / non-positive env value and keeps the built-in default', async () => { |
| 479 | + process.env[HOOK_ENV] = 'not-a-number'; |
| 480 | + await expect(runHook(new QuickJSScriptRunner())).rejects.toThrow(/timeout of 250ms/); |
| 481 | + }, 10000); |
| 482 | +}); |
| 483 | + |
426 | 484 | describe('QuickJSScriptRunner — long-running async host work (pump budget)', () => { |
427 | 485 | // Regression: an action's single `ctx.api.update(...)` can synchronously drive |
428 | 486 | // a large amount of awaited host work — e.g. a record-change flow that the |
|
0 commit comments