fix: prevent memory leaks in experimental scripting sandbox (#5800)

- Cache and reuse a single `FaradayCage` WASM instance to avoid repeated allocations.
- Dispose `InspectionService` watchers via `effectScope` to prevent accumulation on tab switch.
- Use `Set` for environment variable key lookups in validation.
- Dispose Monaco editor models on component unmount.
This commit is contained in:
James George 2026-01-27 10:13:40 +05:30
parent 913863bd09
commit 645ecb55d8
8 changed files with 105 additions and 52 deletions

View file

@ -141,6 +141,8 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
editorModel.value?.dispose()
// Clean up context-specific type definitions for this editor instance // Clean up context-specific type definitions for this editor instance
contextTypeDefRef.value?.dispose() contextTypeDefRef.value?.dispose()

View file

@ -4,7 +4,17 @@ import {
} from "@hoppscotch/data" } from "@hoppscotch/data"
import { refDebounced } from "@vueuse/core" import { refDebounced } from "@vueuse/core"
import { Service } from "dioc" import { Service } from "dioc"
import { Component, Ref, ref, watch, computed, markRaw, reactive } from "vue" import {
Component,
Ref,
ref,
watch,
computed,
markRaw,
reactive,
effectScope,
EffectScope,
} from "vue"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import { RESTTabService } from "../tab/rest" import { RESTTabService } from "../tab/rest"
/** /**
@ -112,8 +122,21 @@ export class InspectionService extends Service {
private readonly restTab = this.bind(RESTTabService) private readonly restTab = this.bind(RESTTabService)
private watcherStopHandle: (() => void) | null = null
private effectScope: EffectScope | null = null
override onServiceInit() { override onServiceInit() {
this.initializeListeners() this.initializeListeners()
// Watch for tab changes and inspector registration to reinitialize
// and create new debounced refs
watch(
() => [this.inspectors.entries(), this.restTab.currentActiveTab.value.id],
() => {
this.initializeListeners()
},
{ flush: "pre" }
)
} }
/** /**
@ -126,56 +149,59 @@ export class InspectionService extends Service {
} }
private initializeListeners() { private initializeListeners() {
watch( // Dispose previous reactive effects
() => [this.inspectors.entries(), this.restTab.currentActiveTab.value.id], this.watcherStopHandle?.()
() => { this.effectScope?.stop()
const currentTabRequest = computed(() => {
if (
this.restTab.currentActiveTab.value.document.type === "test-runner"
)
return null
return this.restTab.currentActiveTab.value.document.type === "request" // Create new effect scope for all computed refs and watchers
? this.restTab.currentActiveTab.value.document.request this.effectScope = effectScope()
: this.restTab.currentActiveTab.value.document.response
.originalRequest
})
const currentTabResponse = computed(() => { this.effectScope.run(() => {
if (this.restTab.currentActiveTab.value.document.type === "request") { const currentTabRequest = computed(() => {
return this.restTab.currentActiveTab.value.document.response if (this.restTab.currentActiveTab.value.document.type === "test-runner")
}
return null return null
})
const reqRef = computed(() => currentTabRequest.value) return this.restTab.currentActiveTab.value.document.type === "request"
const resRef = computed(() => currentTabResponse.value) ? this.restTab.currentActiveTab.value.document.request
: this.restTab.currentActiveTab.value.document.response
.originalRequest
})
const debouncedReq = refDebounced(reqRef, 1000, { maxWait: 2000 }) const currentTabResponse = computed(() => {
const debouncedRes = refDebounced(resRef, 1000, { maxWait: 2000 }) if (this.restTab.currentActiveTab.value.document.type === "request") {
return this.restTab.currentActiveTab.value.document.response
}
return null
})
const inspectorRefs = Array.from(this.inspectors.values()).map((x) => const debouncedReq = refDebounced(currentTabRequest, 1000, {
// @ts-expect-error - This is a valid call maxWait: 2000,
})
const debouncedRes = refDebounced(currentTabResponse, 1000, {
maxWait: 2000,
})
const inspectorRefs = computed(() =>
Array.from(this.inspectors.values()).map((x) =>
x.getInspections(debouncedReq, debouncedRes) x.getInspections(debouncedReq, debouncedRes)
) )
)
const activeInspections = computed(() => const activeInspections = computed(() =>
inspectorRefs.flatMap((x) => x!.value) inspectorRefs.value.flatMap((x) => x?.value ?? [])
) )
watch( this.watcherStopHandle = watch(
() => [...inspectorRefs.flatMap((x) => x!.value)], () => [...activeInspections.value],
() => { () => {
this.tabs.value.set( this.tabs.value.set(
this.restTab.currentActiveTab.value.id, this.restTab.currentActiveTab.value.id,
activeInspections.value activeInspections.value
) )
}, },
{ immediate: true } { immediate: true, flush: "pre" }
) )
}, })
{ immediate: true, flush: "pre" }
)
} }
public deleteTabInspectorResult(tabID: string) { public deleteTabInspectorResult(tabID: string) {

View file

@ -108,7 +108,7 @@ export class EnvironmentInspectorService extends Service implements Inspector {
...collectionVariables, ...collectionVariables,
...this.aggregateEnvsWithValue.value, ...this.aggregateEnvsWithValue.value,
] ]
const envKeys = environmentVariables.map((e) => e.key) const envKeysSet = new Set(environmentVariables.map((e) => e.key))
// Scan each string for <<VAR>> patterns // Scan each string for <<VAR>> patterns
target.forEach((element, index) => { target.forEach((element, index) => {
@ -129,8 +129,7 @@ export class EnvironmentInspectorService extends Service implements Inspector {
key: element, key: element,
} }
// If the variable doesn't exist, add an inspection if (!envKeysSet.has(formattedExEnv)) {
if (!envKeys.includes(formattedExEnv)) {
newErrors.push({ newErrors.push({
id: `environment-not-found-${newErrors.length}`, id: `environment-not-found-${newErrors.length}`,
text: { text: {

View file

@ -1,11 +1,11 @@
import { Cookie, HoppRESTRequest } from "@hoppscotch/data" import { Cookie, HoppRESTRequest } from "@hoppscotch/data"
import { FaradayCage } from "faraday-cage"
import { pipe } from "fp-ts/function" import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/lib/TaskEither" import * as TE from "fp-ts/lib/TaskEither"
import { cloneDeep } from "lodash" import { cloneDeep } from "lodash"
import { defaultModules, preRequestModule } from "~/cage-modules" import { defaultModules, preRequestModule } from "~/cage-modules"
import { HoppFetchHook, SandboxPreRequestResult, TestResult } from "~/types" import { HoppFetchHook, SandboxPreRequestResult, TestResult } from "~/types"
import { acquireCage } from "~/utils/cage"
export const runPreRequestScriptWithFaradayCage = ( export const runPreRequestScriptWithFaradayCage = (
preRequestScript: string, preRequestScript: string,
@ -21,7 +21,7 @@ export const runPreRequestScriptWithFaradayCage = (
let finalRequest = request let finalRequest = request
let finalCookies = cookies let finalCookies = cookies
const cage = await FaradayCage.create() const cage = await acquireCage()
try { try {
const captureHook: { capture?: () => void } = {} const captureHook: { capture?: () => void } = {}

View file

@ -1,5 +1,4 @@
import { HoppRESTRequest } from "@hoppscotch/data" import { HoppRESTRequest } from "@hoppscotch/data"
import { FaradayCage } from "faraday-cage"
import * as TE from "fp-ts/TaskEither" import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function" import { pipe } from "fp-ts/function"
import { cloneDeep } from "lodash" import { cloneDeep } from "lodash"
@ -11,6 +10,7 @@ import {
TestResponse, TestResponse,
TestResult, TestResult,
} from "~/types" } from "~/types"
import { acquireCage } from "~/utils/cage"
export const runPostRequestScriptWithFaradayCage = ( export const runPostRequestScriptWithFaradayCage = (
testScript: string, testScript: string,
@ -30,7 +30,7 @@ export const runPostRequestScriptWithFaradayCage = (
let finalTestResults = testRunStack let finalTestResults = testRunStack
const testPromises: Promise<void>[] = [] const testPromises: Promise<void>[] = []
const cage = await FaradayCage.create() const cage = await acquireCage()
// Wrap entire execution in try-catch to handle QuickJS GC errors that can occur at any point // Wrap entire execution in try-catch to handle QuickJS GC errors that can occur at any point
try { try {

View file

@ -0,0 +1,26 @@
import { FaradayCage } from "faraday-cage"
// Cached cage instance to avoid repeated WASM module allocations.
let cachedCage: FaradayCage | null = null
// Detect if running in a test environment
const isTestEnvironment =
typeof process !== "undefined" && process.env.VITEST === "true"
/**
* Returns a FaradayCage instance, creating and caching it on first access.
* In test environments, always creates a fresh cage to avoid QuickJS GC corruption.
*/
export const acquireCage = async (): Promise<FaradayCage> => {
// In test environments, create a fresh cage to avoid GC corruption
if (isTestEnvironment) {
return FaradayCage.create()
}
// In production, cache the cage for performance
if (!cachedCage) {
cachedCage = await FaradayCage.create()
}
return cachedCage
}

View file

@ -1,4 +1,3 @@
import { FaradayCage } from "faraday-cage"
import { ConsoleEntry } from "faraday-cage/modules" import { ConsoleEntry } from "faraday-cage/modules"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { cloneDeep } from "lodash" import { cloneDeep } from "lodash"
@ -10,6 +9,7 @@ import {
} from "~/types" } from "~/types"
import { defaultModules, preRequestModule } from "~/cage-modules" import { defaultModules, preRequestModule } from "~/cage-modules"
import { acquireCage } from "~/utils/cage"
import { Cookie, HoppRESTRequest } from "@hoppscotch/data" import { Cookie, HoppRESTRequest } from "@hoppscotch/data"
import Worker from "./worker?worker&inline" import Worker from "./worker?worker&inline"
@ -47,7 +47,7 @@ const runPreRequestScriptWithFaradayCage = async (
let finalRequest = request let finalRequest = request
let finalCookies = cookies let finalCookies = cookies
const cage = await FaradayCage.create() const cage = await acquireCage()
try { try {
// Create a hook object to receive the capture function from the module // Create a hook object to receive the capture function from the module

View file

@ -1,4 +1,3 @@
import { FaradayCage } from "faraday-cage"
import { ConsoleEntry } from "faraday-cage/modules" import { ConsoleEntry } from "faraday-cage/modules"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { cloneDeep } from "lodash-es" import { cloneDeep } from "lodash-es"
@ -12,6 +11,7 @@ import {
TestResponse, TestResponse,
TestResult, TestResult,
} from "~/types" } from "~/types"
import { acquireCage } from "~/utils/cage"
import { preventCyclicObjects } from "~/utils/shared" import { preventCyclicObjects } from "~/utils/shared"
import { Cookie, HoppRESTRequest } from "@hoppscotch/data" import { Cookie, HoppRESTRequest } from "@hoppscotch/data"
@ -57,7 +57,7 @@ const runPostRequestScriptWithFaradayCage = async (
let finalCookies = cookies let finalCookies = cookies
const testPromises: Promise<void>[] = [] const testPromises: Promise<void>[] = []
const cage = await FaradayCage.create() const cage = await acquireCage()
try { try {
// Create a hook object to receive the capture function from the module // Create a hook object to receive the capture function from the module