Idempotent tunnel create: reuse existing, block UI double-submit
CI / test (push) Successful in 10m5s
CI / test (push) Successful in 10m5s
Return the active tunnel for the same host:port:protocol instead of starting a second Holesail instance; coalesce concurrent creates. UI disables the create button and shows “already exists” feedback.
This commit is contained in:
@@ -47,10 +47,50 @@ function persistPath() {
|
||||
/** @type {Map<string, { instance: any, info: TunnelInfo }>} */
|
||||
const tunnels = new Map()
|
||||
|
||||
/** In-flight create promises keyed by protocol|host|port (coalesce concurrent creates). */
|
||||
/** @type {Map<string, Promise<TunnelInfo & { existing?: boolean }>>} */
|
||||
const pendingCreates = new Map()
|
||||
|
||||
let HolesailCtor = null
|
||||
let holesailLoadError = null
|
||||
let restoreDone = false
|
||||
|
||||
/**
|
||||
* Normalize host for duplicate matching (localhost ↔ 127.0.0.1).
|
||||
* @param {string} host
|
||||
*/
|
||||
function normalizeTunnelHost(host) {
|
||||
const h = String(host || '127.0.0.1').trim().toLowerCase()
|
||||
if (h === 'localhost' || h === '::1' || h === '0.0.0.0' || h === '::') return '127.0.0.1'
|
||||
return h
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} host
|
||||
* @param {number} port
|
||||
* @param {'tcp'|'udp'} [protocol]
|
||||
*/
|
||||
function targetKey(host, port, protocol = 'tcp') {
|
||||
return `${protocol === 'udp' ? 'udp' : 'tcp'}|${normalizeTunnelHost(host)}|${Number(port)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an active tunnel for the same target (any non-closed state).
|
||||
* @param {string} host
|
||||
* @param {number} port
|
||||
* @param {'tcp'|'udp'} [protocol]
|
||||
* @returns {TunnelInfo|null}
|
||||
*/
|
||||
export function findTunnelByTarget(host, port, protocol = 'tcp') {
|
||||
const key = targetKey(host, port, protocol)
|
||||
for (const t of tunnels.values()) {
|
||||
if (targetKey(t.info.host, t.info.port, t.info.protocol) === key) {
|
||||
return { ...t.info }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function loadHolesail() {
|
||||
if (HolesailCtor) return HolesailCtor
|
||||
if (holesailLoadError) throw holesailLoadError
|
||||
@@ -222,7 +262,7 @@ export function getTunnel(id) {
|
||||
* createdAt?: string,
|
||||
* skipPersistWrite?: boolean,
|
||||
* }} opts
|
||||
* @returns {Promise<TunnelInfo>}
|
||||
* @returns {Promise<TunnelInfo & { existing?: boolean }>}
|
||||
*/
|
||||
export async function createTunnel(opts = {}) {
|
||||
if (!isHolesailEnabled()) {
|
||||
@@ -230,6 +270,54 @@ export async function createTunnel(opts = {}) {
|
||||
err.code = 'FEATURE_DISABLED'
|
||||
throw err
|
||||
}
|
||||
|
||||
const protocol = opts.protocol === 'udp' ? 'udp' : 'tcp'
|
||||
const { host, port } = assertTunnelTarget(opts.host || '127.0.0.1', opts.port)
|
||||
const key = targetKey(host, port, protocol)
|
||||
|
||||
// Idempotent: return existing tunnel for same target (do not start a second Holesail)
|
||||
const existing = findTunnelByTarget(host, port, protocol)
|
||||
if (existing) {
|
||||
logger.info('Tunnel already active — reusing', {
|
||||
id: existing.id,
|
||||
target: `${protocol}://${host}:${port}`,
|
||||
})
|
||||
return { ...existing, existing: true }
|
||||
}
|
||||
|
||||
// Coalesce concurrent createTunnel calls for the same target
|
||||
const inflight = pendingCreates.get(key)
|
||||
if (inflight) {
|
||||
logger.info('Tunnel create already in flight — joining', { target: `${protocol}://${host}:${port}` })
|
||||
const joined = await inflight
|
||||
return { ...joined, existing: true }
|
||||
}
|
||||
|
||||
if (tunnels.size >= MAX_TUNNELS) {
|
||||
throw Object.assign(new Error(`Maximum concurrent tunnels (${MAX_TUNNELS}) reached`), {
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
})
|
||||
}
|
||||
|
||||
const work = createTunnelUnlocked(opts, { host, port, protocol, key })
|
||||
pendingCreates.set(key, work)
|
||||
try {
|
||||
return await work
|
||||
} finally {
|
||||
pendingCreates.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {{ host: string, port: number, protocol: 'tcp'|'udp', key: string }} resolved
|
||||
* @returns {Promise<TunnelInfo & { existing?: boolean }>}
|
||||
*/
|
||||
async function createTunnelUnlocked(opts, resolved) {
|
||||
// Re-check after awaiting any prior work / race window
|
||||
const again = findTunnelByTarget(resolved.host, resolved.port, resolved.protocol)
|
||||
if (again) return { ...again, existing: true }
|
||||
|
||||
if (tunnels.size >= MAX_TUNNELS) {
|
||||
throw Object.assign(new Error(`Maximum concurrent tunnels (${MAX_TUNNELS}) reached`), {
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
@@ -237,28 +325,12 @@ export async function createTunnel(opts = {}) {
|
||||
}
|
||||
|
||||
const Holesail = loadHolesail()
|
||||
const protocol = opts.protocol === 'udp' ? 'udp' : 'tcp'
|
||||
const { host, port, protocol } = resolved
|
||||
const secure = opts.secure !== false
|
||||
const { host, port } = assertTunnelTarget(opts.host || '127.0.0.1', opts.port)
|
||||
const name =
|
||||
String(opts.name || `${protocol}-${host}:${port}`).slice(0, 80) || `tunnel-${port}`
|
||||
const persist = opts.persist !== false
|
||||
|
||||
// Avoid duplicate host:port:protocol
|
||||
for (const t of tunnels.values()) {
|
||||
if (
|
||||
t.info.host === host &&
|
||||
t.info.port === port &&
|
||||
t.info.protocol === protocol &&
|
||||
t.info.state === 'listening'
|
||||
) {
|
||||
throw Object.assign(
|
||||
new Error(`Tunnel already active for ${protocol}://${host}:${port} (${t.info.id})`),
|
||||
{ code: 'DOCKER_CONFLICT' }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const id = opts.id && String(opts.id).startsWith('tnl_') ? String(opts.id) : newId()
|
||||
logger.info('Starting Holesail tunnel', { id, host, port, protocol, secure, persist })
|
||||
|
||||
@@ -289,6 +361,13 @@ export async function createTunnel(opts = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
// Another concurrent path may have registered first
|
||||
const raced = findTunnelByTarget(host, port, protocol)
|
||||
if (raced) {
|
||||
await safeClose(instance)
|
||||
return { ...raced, existing: true }
|
||||
}
|
||||
|
||||
const raw = instance.info || {}
|
||||
/** @type {TunnelInfo} */
|
||||
const info = {
|
||||
@@ -321,7 +400,7 @@ export async function createTunnel(opts = {}) {
|
||||
target: `${protocol}://${host}:${port}`,
|
||||
urlPrefix: info.url.slice(0, 12) + '…',
|
||||
})
|
||||
return { ...info }
|
||||
return { ...info, existing: false }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -425,6 +504,7 @@ export default {
|
||||
isHolesailAvailable,
|
||||
listTunnels,
|
||||
getTunnel,
|
||||
findTunnelByTarget,
|
||||
createTunnel,
|
||||
closeTunnel,
|
||||
closeAllTunnels,
|
||||
|
||||
Reference in New Issue
Block a user