diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index 771e888586..3eedba2e4a 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -327,3 +327,10 @@ as incomplete. Active native turns keep their existing restart/reattach behavior Local and SSH session shutdown behavior is unchanged. A hard process kill cannot guarantee a provider-session checkpoint; scoped files and repository durability remain limited to the last successfully published work-folder checkpoint. + +Deployments must allow enough graceful shutdown time for that drain and other +server cleanup. On Railway, configure at least 60 seconds of draining time for +the service before testing an app redeployment; the platform default is zero. +This is a deployment prerequisite, not a fleet-default promotion. When upgrading +from a release without the idle-session drain, park warm native sessions and +verify their completed harness checkpoints before stopping the old app. diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index c8ff994b11..5702743ac9 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -345,6 +345,12 @@ vi.mock("../services/question-response-delivery.js", () => ({ })), })); +vi.mock("../services/native-runtime/native-session-executor.js", () => ({ + closeIdleSandboxNativeSessionsForShutdown: vi.fn(async () => ({ + closed: 0, busy: 0, failed: 0, + })), +})); + vi.mock("../services/native-runtime/native-question-bridge.js", () => ({ deliverNativeQuestionResponse: vi.fn(async () => "not_native"), nativeQuestionCancellationIdentity: vi.fn(() => null), diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index bc344b63ab..d6e96c6058 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -6510,6 +6510,7 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () => const executionWorkspaceId = randomUUID(); const provisionMarkerPath = path.join(workspaceRoot, "runtime-provisioning.marker"); const markerPath = path.join(workspaceRoot, "runtime-spawned.marker"); + const readyMarkerPath = path.join(workspaceRoot, "runtime-ready.marker"); const provisionScript = [ `require("node:fs").writeFileSync(${JSON.stringify(provisionMarkerPath)}, "provisioning");`, "setTimeout(() => {}, 1200);", @@ -6518,11 +6519,13 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () => `${JSON.stringify(process.execPath)} -e ${JSON.stringify(provisionScript)}`; const serverScript = [ `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "spawned");`, - "setTimeout(() => {", + "const ready = setInterval(() => {", + ` if (!require("node:fs").existsSync(${JSON.stringify(readyMarkerPath)})) return;`, + " clearInterval(ready);", " require(\"node:http\")", " .createServer((_req, res) => { res.end(\"ok\"); })", " .listen(Number(process.env.PORT), \"127.0.0.1\");", - "}, 100);", + "}, 10);", "setInterval(() => {}, 1000);", ].join(" "); const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(serverScript)}`; @@ -6589,7 +6592,9 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () => .from(workspaceRuntimeServices) .where(eq(workspaceRuntimeServices.executionWorkspaceId, executionWorkspaceId)) .then((rows) => rows[0] ?? null); - if (row?.status === status) return row; + if (row?.status === status && (status !== "starting" || row.providerRef !== null)) { + return row; + } await new Promise((resolve) => setTimeout(resolve, 25)); } throw new Error(`Timed out waiting for persisted runtime service status ${status}`); @@ -6670,6 +6675,10 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () => expect(startingRow.providerRef).toMatch(/^\d+$/); expect(startingRow.port).toEqual(expect.any(Number)); + // A process marker can arrive before its PID is persisted. Hold readiness + // until the complete starting row has been observed, rather than racing a + // fixed delay on a busy test runner. + await fs.writeFile(readyMarkerPath, "ready"); const services = await startPromise; expect(services).toHaveLength(1); expect(services[0]).toMatchObject({