Idempotent tunnel create: reuse existing, block UI double-submit
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:
2026-07-10 23:28:07 -04:00
parent f732cab26c
commit 74cd2f4a2e
5 changed files with 197 additions and 28 deletions
+99 -19
View File
@@ -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,