Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions extensions/workflows/sandbox-child.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -433,3 +433,18 @@ function run(source, argsJson, maxConcurrency, initSeq) {
fail(error);
}
}

function announceListening() {
// No token yet. The parent arms the sync watchdog only after this message,
// so process startup is not charged against the script deadline.
sendIpc?.({ kind: "listening" });
}

const startDelay = Number(process.env.OPENPI_SANDBOX_START_DELAY_MS);
if (Number.isFinite(startDelay) && startDelay > 0) {
// Keep the timer referenced. IPC also holds the process, but an unref'd
// delay must not be the reason a child exits before it can listen.
setTimeout(announceListening, startDelay);
} else {
announceListening();
}
73 changes: 49 additions & 24 deletions extensions/workflows/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export interface RunWorkflowSandboxOptions {
* mid-run — losing the aggregate that resuming exists to preserve.
*/
extraAgentRequests?: number;
/**
* Delay before the child announces it is listening. Process startup stays
* outside the synchronous-execution watchdog. Tests use this to show a slow
* spawn is not reported as a script timeout.
*/
startDelayMs?: number;
}

function byteLength(value: string) {
Expand Down Expand Up @@ -192,6 +198,15 @@ export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
env: {
PATH: process.env.PATH ?? "",
NODE_NO_WARNINGS: "1",
...(typeof options.startDelayMs === "number" &&
Number.isFinite(options.startDelayMs) &&
options.startDelayMs > 0
? {
OPENPI_SANDBOX_START_DELAY_MS: String(
Math.floor(options.startDelayMs),
),
}
: {}),
},
stdio: ["ignore", "ignore", "ignore", "ipc"],
},
Expand All @@ -201,6 +216,7 @@ export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
const activeAgentRequests = new Map<number, AbortController>();
let requestCount = 0;
let finished = false;
let scriptStarted = false;
let executionSeq = 0;
let executionWatchdog: NodeJS.Timeout | undefined;

Expand Down Expand Up @@ -272,12 +288,40 @@ export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
);
}
});
const beginExecution = () => {
if (finished || scriptStarted) return;
scriptStarted = true;
// The 1s budget covers non-yielding script after the child is listening.
// Spawn and module load are a precondition, not part of that deadline.
const initSeq = armWatchdog();
child.send(
{
kind: "init",
token,
source: options.source,
argsJson,
maxConcurrency: options.maxConcurrency,
usageJson: usageJson(),
seq: initSeq,
},
(error) => {
if (error) finish(error);
},
);
};

child.on("message", (raw: unknown) => {
if (
!isRecord(raw) ||
raw.token !== token ||
typeof raw.kind !== "string"
) {
if (!isRecord(raw) || typeof raw.kind !== "string") {
finish(new Error("Workflow sandbox sent an invalid IPC message"));
return;
}
// The child has no token yet. A later listening message is ignored so it
// cannot fall through and kill a run that already started.
if (raw.kind === "listening") {
beginExecution();
return;
}
if (raw.token !== token) {
finish(new Error("Workflow sandbox sent an invalid IPC message"));
return;
}
Expand Down Expand Up @@ -437,24 +481,5 @@ export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
}
finish(new Error("Workflow sandbox sent an unknown IPC message"));
});

// Arm the synchronous execution watchdog for the initial script invocation
// (covers non-yielding code before and after initial microtask yields).
const initSeq = armWatchdog();

child.send(
{
kind: "init",
token,
source: options.source,
argsJson,
maxConcurrency: options.maxConcurrency,
usageJson: usageJson(),
seq: initSeq,
},
(error) => {
if (error) finish(error);
},
);
});
}
13 changes: 13 additions & 0 deletions tests/extensions/workflows/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ async function runWithTimeout(
}
}

test("a slow sandbox start is outside the synchronous execution deadline", async () => {
const started = Date.now();
assert.equal(await run("return 1;", { startDelayMs: 1_500 }), 1);
assert.ok(Date.now() - started >= 1_500);
});

test("a delayed start still stops a non-yielding script at the sync deadline", async () => {
await assert.rejects(
runWithTimeout("while (true) {}", { startDelayMs: 1_500 }, 8_000),
/Script execution timed out/,
);
});

test("sandbox exposes only workflow capabilities and validates results", async () => {
const phases: string[] = [];
let active = 0;
Expand Down
7 changes: 5 additions & 2 deletions tests/web/composer-context-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ async function openFileReference(
return reference;
}

it("opens source picking without replacing the draft and rejects another session path", async () => {
it("opens source picking without replacing the draft and rejects another session path", () => {
const { input, update, sendPrompt } = setup();
fireEvent.change(input, { target: { value: "keep this draft" } });
update({
Expand All @@ -180,8 +180,11 @@ it("opens source picking without replacing the draft and rejects another session
revision: 2,
},
});
// The matching request opens the menu in the same flushed render. Polling
// here spends the suite timeout when timers stall, without protecting a
// later readiness condition.
expect(
await screen.findByRole("menuitem", { name: /Reference workspace file/u }),
screen.getByRole("menuitem", { name: /Reference workspace file/u }),
).toBeTruthy();
expect(input.value).toBe("keep this draft");
expect(sendPrompt).not.toHaveBeenCalled();
Expand Down
3 changes: 3 additions & 0 deletions tests/web/session-sidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,9 @@ it("offers an empty pin drop area during a project drag and suppresses the trail
expect(within(pinned).getByText(i18n.t("sidebarDropPin"))).toBeTruthy();
dragPointer(pinned, "dragover");
dragPointer(pinned, "drop");
// Longer than the old 250ms suppression window. A trailing click still must
// not toggle the workspace after a slow post-drop render.
await new Promise((resolve) => setTimeout(resolve, 300));
await waitFor(() =>
expect(pin).toHaveBeenCalledWith(
{ kind: "workspace", path: "/project-1" },
Expand Down
2 changes: 1 addition & 1 deletion web/dist/app.js

Large diffs are not rendered by default.

16 changes: 10 additions & 6 deletions web/ui/src/features/sessions/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,10 @@ export function SessionSidebar(props: SessionSidebarProps) {
const [pinSaving, setPinSaving] = useState(false);
const pinInFlight = useRef(false);
const draggedPin = useRef<WebSidebarPinTarget | null>(null);
const suppressDragClickUntil = useRef(0);
// One trailing click after a drag. Cleared by that click or by a later
// pointerdown. A wall-clock window drops the click when the post-drop render
// is slower than the window.
const suppressDragClick = useRef(false);
const [draggingSidebar, setDraggingSidebar] = useState(false);
const draggedChat = useRef<{
path: string;
Expand Down Expand Up @@ -927,7 +930,7 @@ export function SessionSidebar(props: SessionSidebarProps) {

const endDrag = useCallback(() => {
if (draggedPin.current || draggedChat.current)
suppressDragClickUntil.current = Date.now() + 250;
suppressDragClick.current = true;
draggedPin.current = null;
draggedChat.current = null;
setDraggingSidebar(false);
Expand Down Expand Up @@ -1243,7 +1246,7 @@ export function SessionSidebar(props: SessionSidebarProps) {
return;
}
setDraggingSidebar(true);
suppressDragClickUntil.current = Number.POSITIVE_INFINITY;
suppressDragClick.current = true;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", session.id);
}}
Expand Down Expand Up @@ -1630,7 +1633,7 @@ export function SessionSidebar(props: SessionSidebarProps) {
if (mounted.current && draggedPin.current === target)
setDraggingSidebar(true);
});
suppressDragClickUntil.current = Number.POSITIVE_INFINITY;
suppressDragClick.current = true;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", group.path);
}}
Expand Down Expand Up @@ -1756,12 +1759,13 @@ export function SessionSidebar(props: SessionSidebarProps) {
onPointerDownCapture={() => {
// A new gesture is intentional; only suppress the drag's trailing click.
if (!draggedPin.current && !draggedChat.current)
suppressDragClickUntil.current = 0;
suppressDragClick.current = false;
}}
onClickCapture={(event) => {
if (event.detail !== 0 && Date.now() < suppressDragClickUntil.current) {
if (event.detail !== 0 && suppressDragClick.current) {
event.preventDefault();
event.stopPropagation();
suppressDragClick.current = false;
}
}}
onKeyDown={(event) => {
Expand Down
Loading