fix(coding-agent): isolated promise combinator tracking
Used a run-scoped Promise subclass instead of mutating native combinator methods. Evaluated code can now freeze its Promise constructor without breaking cleanup or later browser runs.
This commit is contained in:
@@ -33,6 +33,7 @@ const observedBrowserPromises = new WeakMap<Promise<unknown>, ObservedPromiseSta
|
||||
const observedPromiseConstructor = { [Symbol.species]: Promise };
|
||||
|
||||
type PromiseCombinatorName = "all" | "race";
|
||||
type PromiseCombinator = (this: PromiseConstructor, values: Iterable<unknown>) => Promise<unknown>;
|
||||
|
||||
interface PromiseCombinatorTrackingContext {
|
||||
owner: object;
|
||||
@@ -40,8 +41,13 @@ interface PromiseCombinatorTrackingContext {
|
||||
}
|
||||
|
||||
const PROMISE_COMBINATORS: readonly PromiseCombinatorName[] = ["all", "race"];
|
||||
const NativePromise = Promise;
|
||||
const nativePromiseCombinators: Record<PromiseCombinatorName, PromiseCombinator> = {
|
||||
all: Promise.all,
|
||||
race: Promise.race,
|
||||
};
|
||||
const promiseCombinatorTracking = new AsyncLocalStorage<PromiseCombinatorTrackingContext>();
|
||||
const originalPromiseCombinatorDescriptors = new Map<PromiseCombinatorName, PropertyDescriptor>();
|
||||
let previousPromiseDescriptor: PropertyDescriptor | undefined;
|
||||
let promiseCombinatorTrackingScopes = 0;
|
||||
|
||||
/**
|
||||
@@ -64,15 +70,40 @@ export async function withBrowserPromiseCombinatorTracking<T>(
|
||||
}
|
||||
|
||||
function installPromiseCombinatorTracking(): void {
|
||||
promiseCombinatorTrackingScopes++;
|
||||
if (promiseCombinatorTrackingScopes !== 1) return;
|
||||
if (promiseCombinatorTrackingScopes > 0) {
|
||||
promiseCombinatorTrackingScopes++;
|
||||
return;
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "Promise");
|
||||
if (!descriptor) throw new Error("Global Promise descriptor is unavailable");
|
||||
const trackedPromise = createTrackedPromiseConstructor();
|
||||
Object.defineProperty(globalThis, "Promise", { ...descriptor, value: trackedPromise });
|
||||
previousPromiseDescriptor = descriptor;
|
||||
promiseCombinatorTrackingScopes = 1;
|
||||
}
|
||||
|
||||
function restorePromiseCombinatorTracking(): void {
|
||||
if (promiseCombinatorTrackingScopes > 1) {
|
||||
promiseCombinatorTrackingScopes--;
|
||||
return;
|
||||
}
|
||||
const descriptor = previousPromiseDescriptor;
|
||||
try {
|
||||
if (!descriptor) throw new Error("Global Promise tracking scope is not installed");
|
||||
Object.defineProperty(globalThis, "Promise", descriptor);
|
||||
} finally {
|
||||
previousPromiseDescriptor = undefined;
|
||||
promiseCombinatorTrackingScopes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function createTrackedPromiseConstructor(): PromiseConstructor {
|
||||
class TrackedPromise<T> extends NativePromise<T> {}
|
||||
for (const name of PROMISE_COMBINATORS) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Promise, name);
|
||||
if (!descriptor || typeof descriptor.value !== "function") continue;
|
||||
originalPromiseCombinatorDescriptors.set(name, descriptor);
|
||||
const original = descriptor.value as (this: PromiseConstructor, values: Iterable<unknown>) => Promise<unknown>;
|
||||
Object.defineProperty(Promise, name, {
|
||||
...descriptor,
|
||||
const original = nativePromiseCombinators[name];
|
||||
Object.defineProperty(TrackedPromise, name, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value(this: PromiseConstructor, values: Iterable<unknown>): Promise<unknown> {
|
||||
let hasObservedInput = false;
|
||||
const result = Reflect.apply(original, this, [
|
||||
@@ -87,15 +118,7 @@ function installPromiseCombinatorTracking(): void {
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function restorePromiseCombinatorTracking(): void {
|
||||
promiseCombinatorTrackingScopes--;
|
||||
if (promiseCombinatorTrackingScopes !== 0) return;
|
||||
for (const [name, descriptor] of originalPromiseCombinatorDescriptors) {
|
||||
Object.defineProperty(Promise, name, descriptor);
|
||||
}
|
||||
originalPromiseCombinatorDescriptors.clear();
|
||||
return TrackedPromise;
|
||||
}
|
||||
|
||||
function* tapObservedBrowserPromises(
|
||||
|
||||
@@ -350,6 +350,37 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("restores promise tracking after evaluated code freezes Promise", async () => {
|
||||
const tool = new BrowserTool(makeSession());
|
||||
const name = `frozen-promise-${process.pid}`;
|
||||
|
||||
try {
|
||||
await tool.execute("open", {
|
||||
action: "open",
|
||||
name,
|
||||
url: "data:text/html,<h1>ready</h1>",
|
||||
});
|
||||
const frozen = await tool.execute("run", {
|
||||
action: "run",
|
||||
name,
|
||||
code: `
|
||||
Object.freeze(Promise);
|
||||
return Object.isFrozen(Promise);
|
||||
`,
|
||||
});
|
||||
expect(frozen.content).toEqual([{ type: "text", text: "true" }]);
|
||||
|
||||
const followup = await tool.execute("run", {
|
||||
action: "run",
|
||||
name,
|
||||
code: "return (await Promise.all([42]))[0];",
|
||||
});
|
||||
expect(followup.content).toEqual([{ type: "text", text: "42" }]);
|
||||
} finally {
|
||||
await tool.execute("close", { action: "close", name, kill: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("folds a user continuation rejection that settles during cleanup", async () => {
|
||||
const tool = new BrowserTool(makeSession());
|
||||
const name = `cleanup-continuation-rejection-${process.pid}`;
|
||||
|
||||
Reference in New Issue
Block a user