Skip to content
File

Blob: worker/tests/runtime/cleanup.test.ts

typescript177 lines
1import { expect, test, vi } from 'vitest';
2import { runDurableObjectAlarm, runInDurableObject } from 'cloudflare:test';
3import { createHarness, device, offer, startedSchema } from './harness.ts';
4 
5test('partial channel allocations survive eviction and failed cleanup before retry', async () => {
6 let partial = true,
7 failClose = false;
8 const h = await createHarness(({ path }) => {
9 if (path.endsWith('/datachannels/new') && partial)
10 return Response.json({
11 dataChannels: [
12 { id: 2, dataChannelName: 'robot' },
13 { dataChannelName: 'spectrum', errorCode: 'creation_error' },
14 ],
15 });
16 if (path.endsWith('/datachannels/close') && failClose)
17 return Response.json({ errorCode: 'retry_later' }, { status: 503 });
18 });
19 const started = await h.call(
20 '/device/start',
21 { sessionDescription: offer, bootId: 'a'.repeat(32) },
22 device,
23 );
24 const identity = { generation: startedSchema.parse(await started.json()).generation };
25 expect((await h.call('/device/channels', identity, device)).status).toBe(502);
26 expect((await h.call('/device/ready', identity, device)).status).toBe(409);
27 await h.evict();
28 failClose = true;
29 const allocations = h.calls.filter((c) => c.path.endsWith('/datachannels/new')).length;
30 expect((await h.call('/device/channels', identity, device)).status).toBe(502);
31 expect(h.calls.filter((c) => c.path.endsWith('/datachannels/new')).length).toBe(allocations);
32 partial = false;
33 failClose = false;
34 expect((await h.call('/device/channels', identity, device)).status).toBe(200);
35 expect(
36 h.calls.some(
37 (c) =>
38 c.path.endsWith('/datachannels/close') &&
39 JSON.stringify(c.input.dataChannels) === '[{"id":2}]',
40 ),
41 ).toBeTruthy();
42 expect((await h.call('/device/ready', identity, device)).status).toBe(200);
43});
44 
45test('channel profile validation retains all returned IDs for retry within the same generation', async () => {
46 let invalid = true;
47 const h = await createHarness(({ path }) => {
48 if (path.endsWith('/datachannels/new') && invalid)
49 return Response.json({
50 dataChannels: [
51 { id: 2, dataChannelName: 'robot', ordered: false },
52 { id: 4, dataChannelName: 'spectrum' },
53 ],
54 });
55 });
56 const response = await h.call(
57 '/device/start',
58 { sessionDescription: offer, bootId: 'a'.repeat(32) },
59 device,
60 );
61 const identity = { generation: startedSchema.parse(await response.json()).generation };
62 expect((await h.call('/device/channels', identity, device)).status).toBe(502);
63 await h.evict();
64 invalid = false;
65 expect((await h.call('/device/channels', identity, device)).status).toBe(200);
66 expect(
67 h.calls.some(
68 (c) =>
69 c.path.endsWith('/datachannels/close') &&
70 JSON.stringify(c.input.dataChannels) === '[{"id":2},{"id":4}]',
71 ),
72 ).toBeTruthy();
73});
74 
75for (const failure of ['bad SDP', 'item error']) {
76 test(`audio allocation is retained before rejecting ${failure}`, async () => {
77 const h = await createHarness(({ path, input }) => {
78 if (path.endsWith('/tracks/new') && input.tracks[0].location === 'remote')
79 return Response.json({
80 sessionDescription: failure === 'bad SDP' ? { type: 'offer', sdp: 'invalid' } : offer,
81 tracks: [{ mid: '7', ...(failure === 'item error' ? { errorCode: 'track_error' } : {}) }],
82 });
83 });
84 await h.login();
85 await h.start();
86 const v = await h.viewer();
87 expect((await h.call(`/viewers/${v.id}/audio`, {}, v.owner)).status).toBe(502);
88 await h.evict();
89 expect((await h.call(`/viewers/${v.id}/leave`, {}, v.owner)).status).toBe(200);
90 expect(
91 h.calls.some(
92 (c) =>
93 c.path.endsWith('/tracks/close') && c.input.tracks.some((track) => track.mid === '7'),
94 ),
95 ).toBeTruthy();
96 expect((await h.status()).viewers).toBe(0);
97 });
98}
99 
100for (const status of [200, 410]) {
101 test(`explicit absence with HTTP ${status} completes viewer cleanup in the current generation`, async () => {
102 let absent = false;
103 const h = await createHarness(({ path }) =>
104 absent && path.endsWith('/close')
105 ? Response.json(
106 { errorCode: status === 200 ? 'close_track_error' : 'session_error' },
107 { status },
108 )
109 : undefined,
110 );
111 await h.login();
112 const { identity } = await h.start();
113 const v = await h.viewer();
114 expect((await h.call(`/viewers/${v.id}/audio`, {}, v.owner)).status).toBe(200);
115 await h.evict();
116 absent = true;
117 expect((await h.call(`/viewers/${v.id}/leave`, {}, v.owner)).status).toBe(200);
118 expect(await h.status()).toMatchObject({ ...identity, online: true, viewers: 0 });
119 expect((await h.call(`/viewers/${v.id}/heartbeat`, {}, v.owner)).status).toBe(403);
120 });
121}
122 
123test('cleanup logs SFU error codes without including response details or SDP', async () => {
124 const warnings = vi.spyOn(console, 'warn').mockImplementation(() => {});
125 const h = await createHarness(({ path }) =>
126 path.endsWith('/tracks/close')
127 ? Response.json({
128 errorCode: 'session_error',
129 errorDescription: 'private upstream detail',
130 sessionDescription: offer,
131 tracks: [{ mid: '0', errorCode: 'track_error' }],
132 })
133 : undefined,
134 );
135 await h.login();
136 await h.start();
137 const v = await h.viewer();
138 expect((await h.call(`/viewers/${v.id}/audio`, {}, v.owner)).status).toBe(200);
139 expect((await h.call(`/viewers/${v.id}/leave`, {}, v.owner)).status).toBe(200);
140 expect(warnings).toHaveBeenCalledWith(
141 JSON.stringify({
142 phase: 'sfu',
143 operation: 'tracks/close',
144 status: 200,
145 errorCode: 'session_error',
146 trackErrorCodes: ['track_error'],
147 }),
148 );
149 expect(JSON.stringify(warnings.mock.calls)).not.toContain('private upstream detail');
150 expect(JSON.stringify(warnings.mock.calls)).not.toContain('UDP/TLS/RTP');
151});
152 
153test('failed viewer cleanup is retried after eviction while its publisher remains current', async () => {
154 let failClose = true;
155 const h = await createHarness(({ path }) => {
156 if (failClose && path.endsWith('/tracks/close'))
157 return Response.json({ tracks: [{ mid: '0', errorCode: 'internal_error' }] });
158 });
159 await h.login();
160 const { identity } = await h.start();
161 const v = await h.viewer();
162 expect((await h.call(`/viewers/${v.id}/audio`, {}, v.owner)).status).toBe(200);
163 expect((await h.call(`/viewers/${v.id}/leave`, {}, v.owner)).status).toBe(200);
164 expect((await h.call(`/viewers/${v.id}/heartbeat`, {}, v.owner)).status).toBe(409);
165 // Suspend the real timer while forcing eviction; status restores the scheduled retry.
166 await runInDurableObject(h.room, (_instance, state) => state.storage.deleteAlarm());
167 await h.evict();
168 failClose = false;
169 await h.status();
170 expect(await runDurableObjectAlarm(h.room)).toBe(true);
171 expect((await h.call(`/viewers/${v.id}/heartbeat`, {}, v.owner)).status).toBe(403);
172 expect(h.calls.filter((call) => call.path.endsWith('/tracks/close'))).toHaveLength(2);
173 expect(h.calls.some((call) => /\/sessions\/session-\d+$/.test(call.path))).toBe(false);
174 expect(h.allocations()).toBe(2);
175 expect(await h.status()).toMatchObject({ ...identity, online: true, viewers: 0 });
176});