Skip to content

Commit 6c947da

Browse files
committed
fix(teams): register the tunnel port individually before hosting
Confirmed against a live devtunnel run: "devtunnel host <id> -p <port> --allow-anonymous" against a persistent (already-created via `devtunnel create`) tunnel fails with: "Tunnel service error: Invalid arguments. Batch update of ports is not supported. Add, update, or delete ports individually instead." DevTunnelProvider.start() now runs `devtunnel port create <id> -p <port> --allow-anonymous` as its own short-lived process first (tolerating an "already exists" failure from a prior partial run), then invokes `devtunnel host <id>` with no -p at all, since the tunnel already has the port registered individually. Ad-hoc/temporary sessions (no tunnelId) are unaffected -- they still host directly with -p in one call, matching prior behaviour. Rewrote devtunnel.test.ts to model both subprocess phases (port create, then host) with distinct fake children.
1 parent 56eca6c commit 6c947da

2 files changed

Lines changed: 141 additions & 23 deletions

File tree

packages/messaging-gateway/src/adapters/teams/tunnel/devtunnel.test.ts

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,46 +12,121 @@ describe('parseTunnelUrl', () => {
1212
})
1313
})
1414

15-
describe('DevTunnelProvider', () => {
16-
it('resolves the public URL parsed from spawn stdout', async () => {
17-
const fakeChild = Object.assign(new EventEmitter(), {
18-
stdout: new EventEmitter(), stderr: new EventEmitter(), kill: () => {},
19-
})
20-
const spawnImpl = (() => fakeChild) as unknown as typeof import('node:child_process').spawn
21-
const p = new DevTunnelProvider({ binPath: '/fake/devtunnel', tunnelId: 't1', spawnImpl })
15+
function fakeChild(): EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: () => void } {
16+
return Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter(), kill: () => {} })
17+
}
18+
19+
describe('DevTunnelProvider — no tunnelId (ad-hoc/temporary session)', () => {
20+
it('hosts directly with -p, no port-create step', async () => {
21+
const child = fakeChild()
22+
const calls: string[][] = []
23+
const spawnImpl = ((_bin: string, args: string[]) => { calls.push(args); return child }) as unknown as typeof import('node:child_process').spawn
24+
const p = new DevTunnelProvider({ binPath: '/fake/devtunnel', spawnImpl })
2225
const started = p.start(3978)
2326
setTimeout(() => {
24-
fakeChild.stdout.emit('data', Buffer.from('Connect via browser: https://t1-3978.usw2.devtunnels.ms\n'))
27+
child.stdout.emit('data', Buffer.from('Connect via browser: https://t1-3978.usw2.devtunnels.ms\n'))
2528
}, 10)
2629
const { publicUrl } = await started
2730
expect(publicUrl).toBe('https://t1-3978.usw2.devtunnels.ms')
31+
expect(calls).toEqual([['host', '--allow-anonymous', '-p', '3978']])
2832
expect(p.isRunning()).toBe(true)
2933
await p.stop()
3034
expect(p.isRunning()).toBe(false)
3135
})
3236

3337
it('rejects when the process exits before a URL is seen', async () => {
34-
const fakeChild = Object.assign(new EventEmitter(), {
35-
stdout: new EventEmitter(), stderr: new EventEmitter(), kill: () => {},
36-
})
37-
const spawnImpl = (() => fakeChild) as unknown as typeof import('node:child_process').spawn
38+
const child = fakeChild()
39+
const spawnImpl = (() => child) as unknown as typeof import('node:child_process').spawn
3840
const p = new DevTunnelProvider({ binPath: '/fake/devtunnel', spawnImpl })
3941
const started = p.start(3978)
40-
setTimeout(() => { fakeChild.emit('exit', 1) }, 10)
42+
setTimeout(() => { child.emit('exit', 1) }, 10)
4143
await expect(started).rejects.toThrow(/devtunnel exited/)
4244
})
4345

4446
it('includes the real stderr text in the rejection instead of a hardcoded guess', async () => {
45-
const fakeChild = Object.assign(new EventEmitter(), {
46-
stdout: new EventEmitter(), stderr: new EventEmitter(), kill: () => {},
47-
})
48-
const spawnImpl = (() => fakeChild) as unknown as typeof import('node:child_process').spawn
49-
const p = new DevTunnelProvider({ binPath: '/fake/devtunnel', tunnelId: 't1', spawnImpl })
47+
const child = fakeChild()
48+
const spawnImpl = (() => child) as unknown as typeof import('node:child_process').spawn
49+
const p = new DevTunnelProvider({ binPath: '/fake/devtunnel', spawnImpl })
5050
const started = p.start(3978)
5151
setTimeout(() => {
52-
fakeChild.stderr.emit('data', Buffer.from('Error: port 3978 is already forwarded on this tunnel\n'))
53-
fakeChild.emit('exit', 1)
52+
child.stderr.emit('data', Buffer.from('Error: port 3978 is already forwarded on this tunnel\n'))
53+
child.emit('exit', 1)
5454
}, 10)
5555
await expect(started).rejects.toThrow(/port 3978 is already forwarded/)
5656
})
5757
})
58+
59+
describe('DevTunnelProvider — persistent tunnelId', () => {
60+
/**
61+
* Real CLI behaviour (confirmed against a live devtunnel run): hosting a
62+
* previously-created (persistent) tunnel with `-p <port>` combined in the
63+
* same `host` invocation fails with "Batch update of ports is not
64+
* supported. Add, update, or delete ports individually instead." The port
65+
* must be registered via a separate `devtunnel port create` call first;
66+
* `host` is then invoked with no `-p` at all.
67+
*/
68+
function twoPhaseSpawn(): {
69+
spawnImpl: typeof import('node:child_process').spawn
70+
portCreateChild: ReturnType<typeof fakeChild>
71+
hostChild: ReturnType<typeof fakeChild>
72+
calls: string[][]
73+
} {
74+
const portCreateChild = fakeChild()
75+
const hostChild = fakeChild()
76+
const calls: string[][] = []
77+
let call = 0
78+
const spawnImpl = ((_bin: string, args: string[]) => {
79+
calls.push(args)
80+
call += 1
81+
return call === 1 ? portCreateChild : hostChild
82+
}) as unknown as typeof import('node:child_process').spawn
83+
return { spawnImpl, portCreateChild, hostChild, calls }
84+
}
85+
86+
it('registers the port individually before hosting, then hosts without -p', async () => {
87+
const { spawnImpl, portCreateChild, hostChild, calls } = twoPhaseSpawn()
88+
const p = new DevTunnelProvider({ binPath: '/fake/devtunnel', tunnelId: 't1', spawnImpl })
89+
const started = p.start(3978)
90+
91+
await new Promise((r) => setTimeout(r, 5))
92+
portCreateChild.emit('exit', 0)
93+
94+
await new Promise((r) => setTimeout(r, 5))
95+
hostChild.stdout.emit('data', Buffer.from('Connect via browser: https://t1-3978.usw2.devtunnels.ms\n'))
96+
97+
const { publicUrl } = await started
98+
expect(publicUrl).toBe('https://t1-3978.usw2.devtunnels.ms')
99+
expect(calls[0]).toEqual(['port', 'create', 't1', '-p', '3978', '--allow-anonymous'])
100+
expect(calls[1]).toEqual(['host', 't1'])
101+
})
102+
103+
it('tolerates "port already exists" from a prior partial run and still hosts', async () => {
104+
const { spawnImpl, portCreateChild, hostChild, calls } = twoPhaseSpawn()
105+
const p = new DevTunnelProvider({ binPath: '/fake/devtunnel', tunnelId: 't1', spawnImpl })
106+
const started = p.start(3978)
107+
108+
await new Promise((r) => setTimeout(r, 5))
109+
portCreateChild.stderr.emit('data', Buffer.from('Error: a port with this number already exists\n'))
110+
portCreateChild.emit('exit', 1)
111+
112+
await new Promise((r) => setTimeout(r, 5))
113+
hostChild.stdout.emit('data', Buffer.from('Connect via browser: https://t1-3978.usw2.devtunnels.ms\n'))
114+
115+
const { publicUrl } = await started
116+
expect(publicUrl).toBe('https://t1-3978.usw2.devtunnels.ms')
117+
expect(calls[1]).toEqual(['host', 't1'])
118+
})
119+
120+
it('rejects without ever hosting when port registration genuinely fails', async () => {
121+
const { spawnImpl, portCreateChild, calls } = twoPhaseSpawn()
122+
const p = new DevTunnelProvider({ binPath: '/fake/devtunnel', tunnelId: 't1', spawnImpl })
123+
const started = p.start(3978)
124+
125+
await new Promise((r) => setTimeout(r, 5))
126+
portCreateChild.stderr.emit('data', Buffer.from('Error: not logged in\n'))
127+
portCreateChild.emit('exit', 1)
128+
129+
await expect(started).rejects.toThrow(/port create failed.*not logged in/s)
130+
expect(calls.length).toBe(1) // host was never invoked
131+
})
132+
})

packages/messaging-gateway/src/adapters/teams/tunnel/devtunnel.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
* DevTunnelProvider — hosts a persistent Azure Dev Tunnel so the public URL is
33
* stable across restarts. Requires a prior one-time `devtunnel user login`.
44
*
5+
* Persistent (already-created) tunnels reject a `host <id> -p <port>` call in
6+
* one shot with: "Tunnel service error: Invalid arguments. Batch update of
7+
* ports is not supported. Add, update, or delete ports individually instead."
8+
* (confirmed against a live devtunnel run). The port must be registered
9+
* individually via `devtunnel port create` first; `host` is then invoked with
10+
* no `-p` at all, since the tunnel already knows which port to forward.
11+
*
512
* NOTE: the exact `devtunnel host` stdout format varies by CLI version.
613
* `parseTunnelUrl` targets the printed `https://<id>.<cluster>.devtunnels.ms`
714
* connect URL; verify against the installed CLI before shipping.
@@ -36,10 +43,45 @@ export class DevTunnelProvider implements TunnelProvider {
3643
if (opts.onUrl) this.urlHandlers.add(opts.onUrl)
3744
}
3845

39-
start(localPort: number): Promise<{ publicUrl: string }> {
46+
async start(localPort: number): Promise<{ publicUrl: string }> {
47+
if (this.opts.tunnelId) {
48+
await this.ensurePortRegistered(this.opts.tunnelId, localPort)
49+
}
50+
return this.runHost(localPort)
51+
}
52+
53+
/**
54+
* Register `localPort` on the persistent tunnel as its own operation. Runs
55+
* to completion (short-lived) before `host` ever starts. Tolerates "already
56+
* exists" so a reconnect after an earlier partial failure doesn't hard-fail
57+
* on a port the tunnel already knows about.
58+
*/
59+
private ensurePortRegistered(tunnelId: string, localPort: number): Promise<void> {
4060
const spawnImpl = this.opts.spawnImpl ?? nodeSpawn
41-
const args = ['host', '--allow-anonymous', '-p', String(localPort)]
42-
if (this.opts.tunnelId) args.push(this.opts.tunnelId)
61+
const args = ['port', 'create', tunnelId, '-p', String(localPort), '--allow-anonymous']
62+
const proc = spawnImpl(this.opts.binPath, args, { stdio: ['ignore', 'pipe', 'pipe'] })
63+
return new Promise((resolve, reject) => {
64+
let stderr = ''
65+
proc.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
66+
proc.on('error', reject)
67+
proc.on('exit', (code) => {
68+
if (code === 0) { resolve(); return }
69+
if (/already exists|already defined/i.test(stderr)) { resolve(); return }
70+
reject(new Error(`devtunnel port create failed (exit ${code}): ${stderr.trim() || 'no output'}`))
71+
})
72+
})
73+
}
74+
75+
/** Start the long-running `devtunnel host` process and resolve once its
76+
* public URL appears in stdout. */
77+
private runHost(localPort: number): Promise<{ publicUrl: string }> {
78+
const spawnImpl = this.opts.spawnImpl ?? nodeSpawn
79+
// A persistent tunnel already has its port registered (ensurePortRegistered
80+
// above); passing -p again here is what triggers the "batch update" error.
81+
// An ad-hoc/temporary session (no tunnelId) still needs -p at host time.
82+
const args = this.opts.tunnelId
83+
? ['host', this.opts.tunnelId]
84+
: ['host', '--allow-anonymous', '-p', String(localPort)]
4385
const proc = spawnImpl(this.opts.binPath, args, { stdio: ['ignore', 'pipe', 'pipe'] })
4486
this.proc = proc
4587

@@ -87,3 +129,4 @@ export class DevTunnelProvider implements TunnelProvider {
87129
onUrlChange(cb: (publicUrl: string) => void): void { this.urlHandlers.add(cb) }
88130
isRunning(): boolean { return this.proc !== null }
89131
}
132+

0 commit comments

Comments
 (0)