estate: every join of a direct invite goes through the pre-join filing; only a Matrix 4xx with an errcode is a refusal
The room card's Join (deep links, permalinks, history) and /join joined through mx.joinRoom and never filed the DM. They now go through activateEstateRoom. A gateway 5xx, or a status with no errcode, has an unknown outcome like a dropped connection: the entry stays and the tap is retryable. The harness gains Soul's s6 deeplink and gw504 drives. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2c04bbefa3
commit
7453a0bd32
7 changed files with 114 additions and 19 deletions
33
harness/estate/drive-routes.cjs
Normal file
33
harness/estate/drive-routes.cjs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
const { chromium } = require('playwright');
|
||||
const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
|
||||
const log = (...a) => console.log(`[${MODE}]`, ...a);
|
||||
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
|
||||
let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c));
|
||||
setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000);
|
||||
(async () => {
|
||||
b = await chromium.launch();
|
||||
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
const pg = await ctx.newPage();
|
||||
await pg.goto('http://127.0.0.1:8080/');
|
||||
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
|
||||
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000);
|
||||
const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
|
||||
const tierOf = (n) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, n);
|
||||
const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM')); };
|
||||
if (MODE === 'deeplink') {
|
||||
for (const route of ['direct', 'home']) {
|
||||
await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000);
|
||||
const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300)));
|
||||
const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count());
|
||||
if (await j.count()) { await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; }
|
||||
}
|
||||
await tiers('after deeplink join');
|
||||
} else if (MODE === 'gw504') {
|
||||
const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]');
|
||||
for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } }
|
||||
log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); await pg.waitForTimeout(4000);
|
||||
const bb = await body(); const k = bb.search(/504|Gateway|connection dropped|Could not join/i); log('AFTER_504', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 120) : 'no message');
|
||||
await pg.waitForTimeout(4000); await tiers('after 504 (join delivered by sync)');
|
||||
}
|
||||
await quit(0);
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });
|
||||
|
|
@ -91,6 +91,7 @@ http.createServer((req, res) => {
|
|||
if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' });
|
||||
const prior = pending.find((q) => q.id === DM2 || q.id === DM3);
|
||||
pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) });
|
||||
if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('<html>504 Gateway Time-out</html>'); }
|
||||
if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); }
|
||||
if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); }
|
||||
return json(res, 200, { room_id: id });
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; }
|
|||
echo BUILD_OK $(git rev-parse HEAD)
|
||||
printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json
|
||||
mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1
|
||||
cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /tmp/pw/
|
||||
cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/
|
||||
set +e
|
||||
for m in twodm reload convert; do
|
||||
HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
|
||||
|
|
@ -20,7 +20,7 @@ done
|
|||
failure() { m=$1; shift
|
||||
env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
|
||||
HS=$!; sleep 2
|
||||
MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?"
|
||||
MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; echo "DRIVE $m RC=$?"
|
||||
kill $HS; wait $HS 2>/dev/null; sleep 1
|
||||
echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; }
|
||||
failure pjfail FAIL_MDIRECT=1
|
||||
|
|
@ -30,3 +30,5 @@ failure admin INVITER_ADMIN=1
|
|||
failure inboxadmin INVITER_ADMIN=1
|
||||
failure inboxreload JOIN_DELAY=1
|
||||
echo DONE
|
||||
DRIVER=drive-routes failure deeplink
|
||||
DRIVER=drive-routes failure gw504 JOIN_504=1
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
|
|
@ -31,6 +31,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room';
|
|||
import { useJoinedRoomId } from '../../hooks/useJoinedRoomId';
|
||||
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
||||
import { getRoomAvatarUrl, getStateEvent } from '../../utils/room';
|
||||
import { joinEstateRoomIdOrAlias } from '../../features/estate/joinEstateRoom';
|
||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
||||
|
|
@ -197,8 +198,11 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||
)
|
||||
);
|
||||
|
||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
||||
useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers])
|
||||
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
|
||||
useCallback(async () => {
|
||||
const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, viaServers);
|
||||
if (result.kind === 'refused') throw new Error(result.message);
|
||||
}, [mx, roomIdOrAlias, viaServers])
|
||||
);
|
||||
const joining =
|
||||
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
|
||||
import { activateEstateRoom, awaitMyJoin, joinEstateRoomIdOrAlias } from './joinEstateRoom';
|
||||
|
||||
const room = (state: 'joined' | 'invited' | 'joinable') => ({
|
||||
roomId: '!r:gamecult.org',
|
||||
|
|
@ -52,13 +52,13 @@ describe('activateEstateRoom', () => {
|
|||
|
||||
it('falls back to the error message, then to a generic one', async () => {
|
||||
const named = recordingClient(
|
||||
vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 }))
|
||||
vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }))
|
||||
);
|
||||
expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({
|
||||
kind: 'refused',
|
||||
message: 'Boom',
|
||||
});
|
||||
const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 500 }));
|
||||
const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' }));
|
||||
expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({
|
||||
kind: 'refused',
|
||||
message: 'Could not join',
|
||||
|
|
@ -78,7 +78,7 @@ describe('activateEstateRoom', () => {
|
|||
});
|
||||
|
||||
/** A client holding DM invites as the server sends them: the mark is in the invite event only. */
|
||||
const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record<string, string> }) => {
|
||||
const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; drop?: boolean; invites: Record<string, string> }) => {
|
||||
let direct: Record<string, string[]> | undefined;
|
||||
const writes: Record<string, string[]>[] = [];
|
||||
const joined: string[] = [];
|
||||
|
|
@ -105,7 +105,8 @@ const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record<stri
|
|||
joinRoom: async (roomId: string) => {
|
||||
// the join lands only after m.direct has been written: record what it held
|
||||
joined.push(JSON.stringify(direct ?? {}));
|
||||
if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403 });
|
||||
if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' });
|
||||
if (opts.status) throw Object.assign(new Error('Gateway Time-out'), { httpStatus: opts.status, errcode: opts.errcode });
|
||||
if (opts.drop) throw new Error('fetch failed');
|
||||
return { roomId };
|
||||
},
|
||||
|
|
@ -149,6 +150,37 @@ describe('activateEstateRoom when the join gets no answer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('activateEstateRoom when a gateway answers instead of the server', () => {
|
||||
it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => {
|
||||
const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } });
|
||||
const result = await activateEstateRoom(c.mx, tap('!dm1:test'));
|
||||
expect(result.kind).toBe('refused');
|
||||
expect((result as { message: string }).message).toMatch(/tap again to retry/);
|
||||
expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] });
|
||||
});
|
||||
|
||||
it('keeps the room listed on a 5xx even when it carries an errcode', async () => {
|
||||
const c = dmClient({ status: 500, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } });
|
||||
await activateEstateRoom(c.mx, tap('!dm1:test'));
|
||||
expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('joinEstateRoomIdOrAlias, the room card and /join', () => {
|
||||
it('files a direct invite opened by its link before the join is sent', async () => {
|
||||
const c = dmClient({ invites: { '!dm1:test': '@friend:test' } });
|
||||
const result = await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['test']);
|
||||
expect(result).toEqual({ kind: 'joined', roomId: '!dm1:test' });
|
||||
expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]);
|
||||
});
|
||||
|
||||
it('joins an alias or an unheld room without touching m.direct', async () => {
|
||||
const { mx, calls } = recordingClient();
|
||||
await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org');
|
||||
expect(calls).toEqual(['joinRoom']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('awaitMyJoin', () => {
|
||||
/** A store that gains the room when the test says so, emitting like the SDK. */
|
||||
const store = () => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export type EstateActivation =
|
|||
| { kind: 'refused'; message: string };
|
||||
|
||||
const LOST_JOIN_RESPONSE =
|
||||
'The connection dropped before the server answered. The join may have gone through; tap again to retry.';
|
||||
'The server did not answer the join. It may have gone through; tap again to retry.';
|
||||
|
||||
type JoinClient = Parameters<typeof addRoomIdToMDirect>[0];
|
||||
|
||||
|
|
@ -21,21 +21,28 @@ type JoinClient = Parameters<typeof addRoomIdToMDirect>[0];
|
|||
* An invite marked is_direct carries the mark in the invite event the client
|
||||
* already holds, and the join event does not repeat it. So the room is listed
|
||||
* in m.direct under the inviter before the join is sent, and taken out again if
|
||||
* the join is refused by a server answer. A join that fails without one (lost
|
||||
* response, dropped connection) may still have been accepted, so the entry
|
||||
* stays and the tap is retryable. Nothing needs to recognise the DM after it
|
||||
* arrives. The inbox Accept goes through here too: one owner for joining.
|
||||
* the join is refused by a Matrix error answer (a 4xx carrying an errcode). A
|
||||
* join that fails any other way (lost response, dropped connection, a gateway's
|
||||
* 5xx or a status with no errcode) may still have been accepted upstream, so the
|
||||
* entry stays and the tap is retryable. Nothing needs to recognise the DM after it
|
||||
* arrives. Every join of a room the client holds an invite for goes through
|
||||
* here: the inbox Accept, the room card, /join. One owner for joining.
|
||||
*/
|
||||
export const activateEstateRoom = async (
|
||||
mx: JoinClient,
|
||||
room: Pick<EstateRoom, 'roomId' | 'state' | 'via'>
|
||||
room: Pick<EstateRoom, 'roomId' | 'state'> & { via?: string[] }
|
||||
): Promise<EstateActivation> => {
|
||||
if (room.state === 'joined') return { kind: 'open', roomId: room.roomId };
|
||||
const refusal = (e: unknown): EstateActivation => {
|
||||
const err = e as { data?: { error?: string }; message?: string };
|
||||
return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' };
|
||||
};
|
||||
const answered = (e: unknown) => typeof (e as { httpStatus?: unknown }).httpStatus === 'number';
|
||||
const refusedByServer = (e: unknown) => {
|
||||
const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } };
|
||||
const status = err.httpStatus;
|
||||
const errcode = err.errcode ?? err.data?.errcode;
|
||||
return typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string';
|
||||
};
|
||||
const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null;
|
||||
const inviter = isDirectInvite(invite, mx.getSafeUserId())
|
||||
? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender()
|
||||
|
|
@ -49,12 +56,27 @@ export const activateEstateRoom = async (
|
|||
const joined = await mx.joinRoom(room.roomId, { viaServers: room.via });
|
||||
return { kind: 'joined', roomId: joined.roomId };
|
||||
} catch (e) {
|
||||
if (!answered(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE };
|
||||
if (!refusedByServer(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE };
|
||||
if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined);
|
||||
return refusal(e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* activateEstateRoom for a caller that holds only an id or alias (room card,
|
||||
* /join): a room the client has an invite for is an invited room.
|
||||
*/
|
||||
export const joinEstateRoomIdOrAlias = (
|
||||
mx: JoinClient,
|
||||
roomIdOrAlias: string,
|
||||
via?: string[]
|
||||
): Promise<EstateActivation> =>
|
||||
activateEstateRoom(mx, {
|
||||
roomId: roomIdOrAlias,
|
||||
state: mx.getRoom(roomIdOrAlias)?.getMyMembership() === 'invite' ? 'invited' : 'joinable',
|
||||
via,
|
||||
});
|
||||
|
||||
type MembershipSource = {
|
||||
getRoom(roomId: string): { getMyMembership(): string } | null;
|
||||
on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { useRoomNavigate } from './useRoomNavigate';
|
|||
import { Membership, StateEvent } from '../../types/matrix/room';
|
||||
import { getStateEvent } from '../utils/room';
|
||||
import { splitWithSpace } from '../utils/common';
|
||||
import { joinEstateRoomIdOrAlias } from '../features/estate/joinEstateRoom';
|
||||
import { createRoomEncryptionState } from '../components/create-room';
|
||||
|
||||
export const SHRUG = '¯\\_(ツ)_/¯';
|
||||
|
|
@ -233,7 +234,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||
(idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
|
||||
);
|
||||
roomIdOrAliases.forEach(async (idOrAlias) => {
|
||||
await mx.joinRoom(idOrAlias);
|
||||
await joinEstateRoomIdOrAlias(mx, idOrAlias);
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue