From 4d2ef12e8e467791cdf57010d086b2e0a13139c9 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Thu, 26 Mar 2026 13:56:11 +0100 Subject: [PATCH 1/7] Fix AI bot messages rendered as user messages due to matrixServerName mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The realm server was overriding `matrixServerName` in the host app config with `matrixClient.matrixURL.hostname` (e.g. `matrix-staging.stack.cards`), but the actual Matrix server_name used in user IDs is different (e.g. `stack.cards`) due to Matrix delegation/.well-known. This caused `aiBotUserId` to be constructed as `@aibot:matrix-staging.stack.cards` instead of `@aibot:stack.cards`, so all AI bot messages were rendered as user messages — no code editor, no diff view, no Apply button for code patches. Fix: read `MATRIX_SERVER_NAME` from the environment (matching how the host app already configures it), falling back to the URL hostname for local dev where they're typically the same. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/realm-server/scripts/start-production.sh | 1 + packages/realm-server/scripts/start-staging.sh | 1 + packages/realm-server/server.ts | 4 +++- packages/software-factory/src/harness/isolated-realm-stack.ts | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/realm-server/scripts/start-production.sh b/packages/realm-server/scripts/start-production.sh index 139f958931..f9e9e42ac6 100755 --- a/packages/realm-server/scripts/start-production.sh +++ b/packages/realm-server/scripts/start-production.sh @@ -25,6 +25,7 @@ EXTERNAL_CATALOG_REALM_URL="${RESOLVED_EXTERNAL_CATALOG_REALM_URL:-$DEFAULT_EXTE NODE_NO_WARNINGS=1 \ LOW_CREDIT_THRESHOLD=2000 \ MATRIX_URL=https://matrix.boxel.ai \ + MATRIX_SERVER_NAME=boxel.ai \ BOXEL_HOST_URL=https://app.boxel.ai \ REALM_SERVER_MATRIX_USERNAME=realm_server \ PUBLISHED_REALM_BOXEL_SPACE_DOMAIN='boxel.space' \ diff --git a/packages/realm-server/scripts/start-staging.sh b/packages/realm-server/scripts/start-staging.sh index 207381bb6a..51d4ed1c75 100755 --- a/packages/realm-server/scripts/start-staging.sh +++ b/packages/realm-server/scripts/start-staging.sh @@ -25,6 +25,7 @@ EXTERNAL_CATALOG_REALM_URL="${RESOLVED_EXTERNAL_CATALOG_REALM_URL:-$DEFAULT_EXTE NODE_NO_WARNINGS=1 \ LOW_CREDIT_THRESHOLD=2000 \ MATRIX_URL=https://matrix-staging.stack.cards \ + MATRIX_SERVER_NAME=stack.cards \ BOXEL_HOST_URL=https://realms-staging.stack.cards \ REALM_SERVER_MATRIX_USERNAME=realm_server \ PUBLISHED_REALM_BOXEL_SPACE_DOMAIN='staging.boxel.dev' \ diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index 4a08890d43..53a251f3c7 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -707,7 +707,9 @@ export class RealmServer { hostsOwnAssets: false, assetsURL: this.assetsURL.href, matrixURL: this.matrixClient.matrixURL.href.replace(/\/$/, ''), - matrixServerName: this.matrixClient.matrixURL.hostname, + matrixServerName: + process.env.MATRIX_SERVER_NAME || + this.matrixClient.matrixURL.hostname, realmServerURL: this.serverURL.href, resolvedBaseRealmURL: rewriteRealmURL(config.resolvedBaseRealmURL), resolvedCatalogRealmURL: rewriteRealmURL( diff --git a/packages/software-factory/src/harness/isolated-realm-stack.ts b/packages/software-factory/src/harness/isolated-realm-stack.ts index 74a621d297..fe9961d780 100644 --- a/packages/software-factory/src/harness/isolated-realm-stack.ts +++ b/packages/software-factory/src/harness/isolated-realm-stack.ts @@ -340,6 +340,7 @@ export async function startIsolatedRealmStack({ REALM_SECRET_SEED, GRAFANA_SECRET, MATRIX_URL: context.matrixURL, + MATRIX_SERVER_NAME: new URL(context.matrixURL).hostname, MATRIX_REGISTRATION_SHARED_SECRET: context.matrixRegistrationSecret, REALM_SERVER_MATRIX_USERNAME: DEFAULT_MATRIX_SERVER_USERNAME, REALM_SERVER_FULL_INDEX_ON_STARTUP: String(fullIndexOnStartup), From 9944eaf05b37639c2cd9176eb11f17609980f629 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 25 Mar 2026 18:01:52 -0400 Subject: [PATCH 2/7] Fix software factory cache prepare in worktrees --- .../software-factory/src/cli/cache-realm.ts | 18 +- .../src/harness/isolated-realm-stack.ts | 1 + .../software-factory/src/harness/shared.ts | 58 +++++- .../src/harness/support-services.ts | 178 ++++++++++++++---- 4 files changed, 205 insertions(+), 50 deletions(-) diff --git a/packages/software-factory/src/cli/cache-realm.ts b/packages/software-factory/src/cli/cache-realm.ts index 7cb7a9436c..dd6f1931fb 100644 --- a/packages/software-factory/src/cli/cache-realm.ts +++ b/packages/software-factory/src/cli/cache-realm.ts @@ -1,10 +1,8 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; -import { - ensureFactoryRealmTemplate, - type FactoryRealmOptions, -} from '../harness'; +import { ensureFactoryRealmTemplate } from '../harness'; +import { isFactorySupportContext } from '../harness/shared'; import { readSupportContext } from '../runtime-metadata'; async function main(): Promise { @@ -18,9 +16,15 @@ async function main(): Promise { ]; let serializedSupportContext = process.env.SOFTWARE_FACTORY_CONTEXT; - let supportContext: FactoryRealmOptions['context'] = serializedSupportContext - ? (JSON.parse(serializedSupportContext) as FactoryRealmOptions['context']) - : (readSupportContext() as FactoryRealmOptions['context']); + let parsedEnvContext = serializedSupportContext + ? (JSON.parse(serializedSupportContext) as unknown) + : undefined; + let parsedMetadataContext = readSupportContext(); + let supportContext = isFactorySupportContext(parsedEnvContext) + ? parsedEnvContext + : isFactorySupportContext(parsedMetadataContext) + ? parsedMetadataContext + : undefined; let preparedTemplates = []; for (let realmDir of realmDirs) { diff --git a/packages/software-factory/src/harness/isolated-realm-stack.ts b/packages/software-factory/src/harness/isolated-realm-stack.ts index fe9961d780..828d2c62c2 100644 --- a/packages/software-factory/src/harness/isolated-realm-stack.ts +++ b/packages/software-factory/src/harness/isolated-realm-stack.ts @@ -339,6 +339,7 @@ export async function startIsolatedRealmStack({ REALM_SERVER_SECRET_SEED, REALM_SECRET_SEED, GRAFANA_SECRET, + HOST_URL: context.hostURL, MATRIX_URL: context.matrixURL, MATRIX_SERVER_NAME: new URL(context.matrixURL).hostname, MATRIX_REGISTRATION_SHARED_SECRET: context.matrixRegistrationSecret, diff --git a/packages/software-factory/src/harness/shared.ts b/packages/software-factory/src/harness/shared.ts index cf7dda2101..3710f3f43c 100644 --- a/packages/software-factory/src/harness/shared.ts +++ b/packages/software-factory/src/harness/shared.ts @@ -6,7 +6,7 @@ import { import { createHash } from 'node:crypto'; import { createServer as createNetServer } from 'node:net'; import { readdirSync, readFileSync, statSync } from 'node:fs'; -import { join, relative, resolve } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import jwt from 'jsonwebtoken'; import '../setup-logger'; @@ -19,6 +19,7 @@ export type RealmPermissions = Record; export type FactorySupportContext = { matrixURL: string; matrixRegistrationSecret: string; + hostURL: string; }; export type SynapseInstance = { @@ -153,10 +154,11 @@ export const DEFAULT_REALM_DIR = resolve( packageRoot, process.env.SOFTWARE_FACTORY_REALM_DIR ?? 'test-fixtures/darkfactory-adopter', ); -export const DEFAULT_HOST_URL = - process.env.HOST_URL ?? 'http://localhost:4200/'; export const DEFAULT_ICONS_URL = process.env.ICONS_URL ?? 'http://localhost:4206/'; +export const CONFIGURED_HOST_URL = process.env.SOFTWARE_FACTORY_HOST_URL + ? new URL(process.env.SOFTWARE_FACTORY_HOST_URL) + : undefined; export const DEFAULT_ICONS_PROBE_URL = new URL( '@cardstack/boxel-icons/v1/icons/code.js', DEFAULT_ICONS_URL, @@ -586,15 +588,40 @@ export function fileExists(path: string): boolean { } } +export function findRootRepoCheckoutDir(): string | undefined { + let result = spawnSync( + 'git', + ['rev-parse', '--path-format=absolute', '--git-common-dir'], + { + cwd: workspaceRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }, + ); + + if (result.status !== 0) { + return undefined; + } + + let commonDir = result.stdout.trim(); + if (!commonDir.endsWith(`${join('.git')}`)) { + return undefined; + } + + return dirname(commonDir); +} + export function findHostDistPackageDir(): string | undefined { - let siblingRoot = resolve(workspaceRoot, '..'); + let rootRepoCheckoutDir = findRootRepoCheckoutDir(); + let rootRepoHostDir = + rootRepoCheckoutDir && rootRepoCheckoutDir !== workspaceRoot + ? resolve(rootRepoCheckoutDir, 'packages', 'host') + : undefined; + let candidates = [ process.env.SOFTWARE_FACTORY_HOST_DIST_PACKAGE_DIR, - resolve(siblingRoot, 'boxel', 'packages', 'host'), - ...readdirSync(siblingRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => resolve(siblingRoot, entry.name, 'packages', 'host')), hostDir, + rootRepoHostDir, ] .filter((value): value is string => Boolean(value)) .map((value) => resolve(value)); @@ -630,6 +657,21 @@ export function parseFactoryContext(): FactoryTestContext | undefined { return JSON.parse(raw) as FactoryTestContext; } +export function isFactorySupportContext( + context: unknown, +): context is FactorySupportContext { + return Boolean( + context && + typeof context === 'object' && + 'matrixURL' in context && + typeof context.matrixURL === 'string' && + 'matrixRegistrationSecret' in context && + typeof context.matrixRegistrationSecret === 'string' && + 'hostURL' in context && + typeof context.hostURL === 'string', + ); +} + export function hasTemplateDatabaseName( context: FactorySupportContext | FactoryTestContext, ): context is FactoryTestContext { diff --git a/packages/software-factory/src/harness/support-services.ts b/packages/software-factory/src/harness/support-services.ts index 5c24de5e40..75682544f6 100644 --- a/packages/software-factory/src/harness/support-services.ts +++ b/packages/software-factory/src/harness/support-services.ts @@ -1,19 +1,21 @@ import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { boxelIconsDir, browserPassword, cleanupStaleSynapseContainers, - DEFAULT_HOST_URL, DEFAULT_ICONS_PROBE_URL, DEFAULT_MATRIX_BROWSER_USERNAME, DEFAULT_MATRIX_SERVER_USERNAME, DEFAULT_PG_HOST, DEFAULT_PG_PORT, DEFAULT_PRERENDER_PORT, + CONFIGURED_HOST_URL, findAvailablePort, findHostDistPackageDir, - hostDir, + findRootRepoCheckoutDir, logTimed, maybeRequire, prepareTestPgScript, @@ -33,6 +35,91 @@ function hostStartupLooksLikePortContention(logs: string): boolean { return /EADDRINUSE|address already in use/i.test(logs); } +function assertUsableBoxelUIDist(hostPackageDir: string): void { + let boxelUIAddonDir = join(hostPackageDir, '..', 'boxel-ui', 'addon'); + let boxelUIDistDir = join(boxelUIAddonDir, 'dist'); + let requiredPaths = [ + join(boxelUIDistDir, 'components.js'), + join(boxelUIDistDir, 'helpers.js'), + join(boxelUIDistDir, 'icons.js'), + join(boxelUIDistDir, 'styles', 'global.css'), + ]; + + if (requiredPaths.every((path) => existsSync(path))) { + return; + } + + let rootRepoCheckoutDir = findRootRepoCheckoutDir(); + let rootRepoBoxelUIAddonDir = + rootRepoCheckoutDir && rootRepoCheckoutDir !== workspaceRoot + ? join(rootRepoCheckoutDir, 'packages', 'boxel-ui', 'addon') + : undefined; + let rootRepoBoxelUIDistDir = rootRepoBoxelUIAddonDir + ? join(rootRepoBoxelUIAddonDir, 'dist') + : undefined; + let hasRootRepoBoxelUIDist = + rootRepoBoxelUIDistDir != null && + requiredPaths + .map((path) => + join(rootRepoBoxelUIDistDir, path.slice(boxelUIDistDir.length + 1)), + ) + .every((path) => existsSync(path)); + + let fixInstructions = [ + `Run \`cd ${boxelUIAddonDir} && mise exec -- pnpm build\` to build boxel-ui in this checkout.`, + ]; + + if (hasRootRepoBoxelUIDist && rootRepoBoxelUIDistDir) { + fixInstructions.push( + `If you are in a worktree and want to reuse the main checkout build, run \`ln -sfn ${rootRepoBoxelUIDistDir} ${boxelUIDistDir}\`.`, + ); + } + + throw new Error( + `Boxel UI dist is missing or incomplete at ${boxelUIDistDir}. The software-factory harness needs built @cardstack/boxel-ui artifacts before the host app can boot. ${fixInstructions.join( + ' ', + )}`, + ); +} + +function assertUsableHostDist(hostPackageDir: string): void { + let indexHTMLPath = join(hostPackageDir, 'dist', 'index.html'); + if (!existsSync(indexHTMLPath)) { + throw new Error( + `No built host dist was found at ${indexHTMLPath}. The software-factory harness requires a built host app from the current worktree or root repo checkout. Run \`cd ${hostPackageDir} && mise exec -- pnpm build\` and retry.`, + ); + } + + let html = readFileSync(indexHTMLPath, 'utf8'); + let match = html.match( + //, + ); + if (!match) { + return; + } + + try { + let config = JSON.parse(decodeURIComponent(match[1])); + if ( + config?.environment === 'test' || + config?.APP?.autoboot === false || + config?.APP?.rootElement === '#ember-testing' + ) { + throw new Error( + `Host dist at ${hostPackageDir}/dist is a test build and cannot power the software-factory harness (environment=${String( + config?.environment, + )}, autoboot=${String(config?.APP?.autoboot)}, rootElement=${String( + config?.APP?.rootElement, + )}). The harness needs a normal host app build so /_standby can boot. Run \`cd ${hostPackageDir} && mise exec -- pnpm build\` and retry.`, + ); + } + } catch (error) { + if (error instanceof Error) { + throw error; + } + } +} + async function loadSynapseModule() { let moduleSpecifier = '../../../matrix/docker/synapse/index.ts'; return (maybeRequire(moduleSpecifier) ?? (await import(moduleSpecifier))) as { @@ -61,49 +148,68 @@ async function loadMatrixEnvironmentConfigModule() { }; } -async function ensureHostReady(matrixURL: string): Promise<{ +async function ensureHostReady(): Promise<{ + hostURL: string; stop?: () => Promise; }> { + let configuredHostURL = CONFIGURED_HOST_URL?.href; return await logTimed( supportLog, - `ensureHostReady ${DEFAULT_HOST_URL}`, + `ensureHostReady ${configuredHostURL ?? 'dynamic host dist'}`, async () => { - let response: Response; - try { - response = await fetch(DEFAULT_HOST_URL); - if (response.ok) { - return {}; + if (configuredHostURL) { + try { + let response = await fetch(configuredHostURL); + if (response.ok) { + return { hostURL: configuredHostURL }; + } + throw new Error( + `configured software-factory host URL ${configuredHostURL} returned HTTP ${response.status}`, + ); + } catch (error) { + throw new Error( + `configured software-factory host URL ${configuredHostURL} is not reachable: ${ + error instanceof Error ? error.message : String(error) + }`, + ); } - } catch (error) { - supportLog.debug( - `host app not reachable at ${DEFAULT_HOST_URL}, starting fallback host service: ${ - error instanceof Error ? error.message : String(error) - }`, - ); } let hostPackageDir = findHostDistPackageDir(); - let command = ['start']; - let cwd = hostDir; - if (hostPackageDir) { - supportLog.debug(`serving built host dist from ${hostPackageDir}`); - command = ['serve:dist']; - cwd = hostPackageDir; - } else { - supportLog.warn( - 'no built host dist found; falling back to pnpm start in packages/host', + if (!hostPackageDir) { + throw new Error( + 'No built host dist is available in the current worktree or root repo checkout', ); } + assertUsableBoxelUIDist(hostPackageDir); + assertUsableHostDist(hostPackageDir); + let port = await findAvailablePort(); + let hostURL = `http://localhost:${port}/`; + supportLog.debug( + `serving built host dist from ${hostPackageDir} at ${hostURL}`, + ); - let child = spawn('pnpm', command, { - cwd, - detached: true, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - MATRIX_URL: matrixURL, + let child = spawn( + 'npx', + [ + 'serve', + '--config', + '../tests/serve.json', + '--single', + '--cors', + '--no-request-logging', + '--no-etag', + '--listen', + String(port), + 'dist', + ], + { + cwd: hostPackageDir, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, }, - }); + ); let logs = ''; child.stdout?.on('data', (chunk) => { @@ -116,7 +222,7 @@ async function ensureHostReady(matrixURL: string): Promise<{ await waitUntil( async () => { try { - let readyResponse = await fetch(DEFAULT_HOST_URL); + let readyResponse = await fetch(hostURL); if (readyResponse.ok) { return true; } @@ -136,11 +242,12 @@ async function ensureHostReady(matrixURL: string): Promise<{ { timeout: 180_000, interval: 500, - timeoutMessage: `Timed out waiting for host app at ${DEFAULT_HOST_URL}\n${logs}`, + timeoutMessage: `Timed out waiting for host app at ${hostURL}\n${logs}`, }, ); return { + hostURL, async stop() { if (child.exitCode === null) { try { @@ -401,7 +508,7 @@ export async function startFactorySupportServices(): Promise<{ ); let matrixURL = process.env.SOFTWARE_FACTORY_MATRIX_URL ?? getSynapseURL(synapse); - let host = await ensureHostReady(matrixURL); + let host = await ensureHostReady(); let icons = await ensureIconsReady(); await ensureSupportUsers(synapse); @@ -409,6 +516,7 @@ export async function startFactorySupportServices(): Promise<{ context: { matrixURL, matrixRegistrationSecret: synapse.registrationSecret, + hostURL: host.hostURL, }, async stop() { await logTimed(supportLog, 'stopFactorySupportServices', async () => { From 95f442ec953bba4688f9ae67d8c9cec59c7065ad Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 25 Mar 2026 18:10:00 -0400 Subject: [PATCH 3/7] Fix host serve config path --- packages/software-factory/src/harness/support-services.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/software-factory/src/harness/support-services.ts b/packages/software-factory/src/harness/support-services.ts index 75682544f6..8357880c38 100644 --- a/packages/software-factory/src/harness/support-services.ts +++ b/packages/software-factory/src/harness/support-services.ts @@ -194,7 +194,7 @@ async function ensureHostReady(): Promise<{ [ 'serve', '--config', - '../tests/serve.json', + 'tests/serve.json', '--single', '--cors', '--no-request-logging', From 0cefad263197c0b12d13f1735a2af9187e896892 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 25 Mar 2026 18:17:50 -0400 Subject: [PATCH 4/7] Use absolute host serve paths --- packages/software-factory/src/harness/support-services.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/software-factory/src/harness/support-services.ts b/packages/software-factory/src/harness/support-services.ts index 8357880c38..b689799e4b 100644 --- a/packages/software-factory/src/harness/support-services.ts +++ b/packages/software-factory/src/harness/support-services.ts @@ -185,6 +185,8 @@ async function ensureHostReady(): Promise<{ assertUsableHostDist(hostPackageDir); let port = await findAvailablePort(); let hostURL = `http://localhost:${port}/`; + let hostDistDir = join(hostPackageDir, 'dist'); + let serveConfigPath = join(hostPackageDir, 'tests', 'serve.json'); supportLog.debug( `serving built host dist from ${hostPackageDir} at ${hostURL}`, ); @@ -194,14 +196,14 @@ async function ensureHostReady(): Promise<{ [ 'serve', '--config', - 'tests/serve.json', + serveConfigPath, '--single', '--cors', '--no-request-logging', '--no-etag', '--listen', String(port), - 'dist', + hostDistDir, ], { cwd: hostPackageDir, From 7b1ed8074cf5f7148fe5bb5380e1e0ebac3a583f Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 26 Mar 2026 11:17:01 -0400 Subject: [PATCH 5/7] Fix software factory harness collisions --- packages/matrix/docker/synapse/index.ts | 78 +++++++++++++++---- .../software-factory/src/harness/shared.ts | 27 +++++-- 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/packages/matrix/docker/synapse/index.ts b/packages/matrix/docker/synapse/index.ts index 979cc56357..5b2291557e 100644 --- a/packages/matrix/docker/synapse/index.ts +++ b/packages/matrix/docker/synapse/index.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; +import * as net from 'net'; import * as fse from 'fs-extra'; import { request } from '@playwright/test'; import { @@ -41,6 +42,39 @@ export interface SynapseInstance extends SynapseConfig { const synapses = new Map(); +function findAvailablePort(preferred?: number): Promise { + return new Promise((resolve, reject) => { + let server = net.createServer(); + + server.on('error', (error: NodeJS.ErrnoException) => { + server.close(); + if (preferred != null && error.code === 'EADDRINUSE') { + findAvailablePort(undefined).then(resolve, reject); + return; + } + reject(error); + }); + + server.listen(preferred ?? 0, '127.0.0.1', () => { + let address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => + reject(new Error('Could not determine available port')), + ); + return; + } + let { port } = address; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + }); +} + function randB64Bytes(numBytes: number): string { return crypto.randomBytes(numBytes).toString('base64').replace(/=*$/, ''); } @@ -48,6 +82,11 @@ function randB64Bytes(numBytes: number): string { export async function cfgDirFromTemplate( template: string, dataDir?: string, + options?: { + publicBaseUrl?: string; + host?: string; + port?: number; + }, ): Promise { const templateDir = path.join(__dirname, template); @@ -69,7 +108,9 @@ export async function cfgDirFromTemplate( const macaroonSecret = randB64Bytes(16); const formSecret = randB64Bytes(16); - const baseUrl = `http://${SYNAPSE_IP_ADDRESS}:${SYNAPSE_PORT}`; + const host = options?.host ?? SYNAPSE_IP_ADDRESS; + const port = options?.port ?? SYNAPSE_PORT; + const baseUrl = options?.publicBaseUrl ?? `http://${host}:${port}`; // now copy homeserver.yaml, applying substitutions console.log(`Gen ${path.join(templateDir, 'homeserver.yaml')}`); @@ -95,8 +136,8 @@ export async function cfgDirFromTemplate( ); return { - port: SYNAPSE_PORT, - host: SYNAPSE_IP_ADDRESS, + port, + host, baseUrl, configDir, registrationSecret, @@ -136,9 +177,18 @@ export async function synapseStart( } await Promise.allSettled(stopPromises); } + let useDynamicHostPort = Boolean( + isEnvironmentMode() || opts?.dynamicHostPort, + ); + let hostPort = useDynamicHostPort ? await findAvailablePort() : SYNAPSE_PORT; const synCfg = await cfgDirFromTemplate( opts?.template ?? 'test', opts?.dataDir, + { + host: useDynamicHostPort ? '127.0.0.1' : SYNAPSE_IP_ADDRESS, + port: hostPort, + publicBaseUrl: `http://localhost:${hostPort}`, + }, ); let containerName = opts?.containerName || @@ -157,12 +207,10 @@ export async function synapseStart( '-v', `${path.join(__dirname, 'templates')}:/custom/templates/`, ]; - if (isEnvironmentMode() || opts?.dynamicHostPort) { - // Dynamic host port, with fixed container IP only when not running in branch mode - if (!isEnvironmentMode()) { - dockerParams.push(`--ip=${synCfg.host}`); - } - dockerParams.push('-p', '0:8008/tcp', '--network=boxel'); + if (useDynamicHostPort) { + // In dynamic-host-port mode multiple harnesses may run concurrently, so + // we must not claim the shared fixed Synapse container IP. + dockerParams.push('-p', `${hostPort}:8008/tcp`, '--network=boxel'); } else { dockerParams.push( `--ip=${synCfg.host}`, @@ -180,7 +228,7 @@ export async function synapseStart( runAsUser: true, }); - console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}`); + console.log(`Started synapse with id ${synapseId} on port ${hostPort}`); // Await Synapse healthcheck await dockerExec({ @@ -199,9 +247,13 @@ export async function synapseStart( ], }); - let hostPort = synCfg.port; - if (isEnvironmentMode() || opts?.dynamicHostPort) { - hostPort = await resolveHostPort(synapseId); + if (useDynamicHostPort) { + let resolvedPort = await resolveHostPort(synapseId); + if (resolvedPort !== hostPort) { + throw new Error( + `Synapse started on unexpected host port ${resolvedPort}; expected ${hostPort}`, + ); + } console.log(`Synapse dynamic host port: ${hostPort}`); } diff --git a/packages/software-factory/src/harness/shared.ts b/packages/software-factory/src/harness/shared.ts index 3710f3f43c..ffe03999a5 100644 --- a/packages/software-factory/src/harness/shared.ts +++ b/packages/software-factory/src/harness/shared.ts @@ -781,19 +781,36 @@ export async function stopManagedProcess(proc: SpawnedProcess): Promise { if (proc.exitCode !== null) { return; } - let stopped = new Promise((resolve) => { + let stopped = new Promise((resolve) => { let onMessage = (message: unknown) => { if (message === 'stopped') { proc.off('message', onMessage); - resolve(); + resolve(true); } }; proc.on('message', onMessage); }); + let exited = new Promise((resolve) => { + let onExit = () => { + proc.off('exit', onExit); + proc.off('error', onExit); + resolve(); + }; + proc.on('exit', onExit); + proc.on('error', onExit); + }); proc.send('stop'); - await Promise.race([ + let stoppedGracefully = await Promise.race([ stopped, - new Promise((resolve) => setTimeout(resolve, 15_000)), + new Promise((resolve) => setTimeout(() => resolve(false), 15_000)), ]); - proc.send('kill'); + if (!stoppedGracefully && proc.exitCode === null) { + proc.send('kill'); + } + if (proc.exitCode === null) { + await Promise.race([ + exited, + new Promise((resolve) => setTimeout(resolve, 15_000)), + ]); + } } From 61977b703f0f015b47b95b5ec6c58932ab3d70d0 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 26 Mar 2026 12:03:39 -0400 Subject: [PATCH 6/7] Address PR review feedback --- packages/matrix/docker/synapse/index.ts | 112 +++++++++++------- packages/realm-server/tests/index.ts | 1 + .../realm-server/tests/server-config-test.ts | 65 ++++++++++ .../software-factory/src/harness/shared.ts | 33 ++++-- 4 files changed, 161 insertions(+), 50 deletions(-) create mode 100644 packages/realm-server/tests/server-config-test.ts diff --git a/packages/matrix/docker/synapse/index.ts b/packages/matrix/docker/synapse/index.ts index 5b2291557e..eebd8c9043 100644 --- a/packages/matrix/docker/synapse/index.ts +++ b/packages/matrix/docker/synapse/index.ts @@ -41,6 +41,7 @@ export interface SynapseInstance extends SynapseConfig { } const synapses = new Map(); +const dynamicHostPortStartAttempts = 5; function findAvailablePort(preferred?: number): Promise { return new Promise((resolve, reject) => { @@ -79,6 +80,11 @@ function randB64Bytes(numBytes: number): string { return crypto.randomBytes(numBytes).toString('base64').replace(/=*$/, ''); } +function isPortBindError(error: unknown): boolean { + let message = error instanceof Error ? error.message : String(error); + return /address already in use|port is already allocated/i.test(message); +} + export async function cfgDirFromTemplate( template: string, dataDir?: string, @@ -180,53 +186,75 @@ export async function synapseStart( let useDynamicHostPort = Boolean( isEnvironmentMode() || opts?.dynamicHostPort, ); - let hostPort = useDynamicHostPort ? await findAvailablePort() : SYNAPSE_PORT; - const synCfg = await cfgDirFromTemplate( - opts?.template ?? 'test', - opts?.dataDir, - { + await dockerCreateNetwork({ networkName: 'boxel' }); + + let hostPort = SYNAPSE_PORT; + let synCfg!: SynapseConfig; + let containerName!: string; + let synapseId!: string; + let attempts = useDynamicHostPort ? dynamicHostPortStartAttempts : 1; + + for (let attempt = 1; attempt <= attempts; attempt++) { + hostPort = useDynamicHostPort ? await findAvailablePort() : SYNAPSE_PORT; + synCfg = await cfgDirFromTemplate(opts?.template ?? 'test', opts?.dataDir, { host: useDynamicHostPort ? '127.0.0.1' : SYNAPSE_IP_ADDRESS, port: hostPort, publicBaseUrl: `http://localhost:${hostPort}`, - }, - ); - let containerName = - opts?.containerName || - (isEnvironmentMode() - ? getSynapseContainerName() - : path.basename(synCfg.configDir)); - console.log( - `Starting synapse with config dir ${synCfg.configDir} in container ${containerName}...`, - ); - await dockerCreateNetwork({ networkName: 'boxel' }); - - let dockerParams: string[] = [ - '--rm', - '-v', - `${synCfg.configDir}:/data`, - '-v', - `${path.join(__dirname, 'templates')}:/custom/templates/`, - ]; - if (useDynamicHostPort) { - // In dynamic-host-port mode multiple harnesses may run concurrently, so - // we must not claim the shared fixed Synapse container IP. - dockerParams.push('-p', `${hostPort}:8008/tcp`, '--network=boxel'); - } else { - dockerParams.push( - `--ip=${synCfg.host}`, - '-p', - `${synCfg.port}:8008/tcp`, - '--network=boxel', + }); + containerName = + opts?.containerName || + (isEnvironmentMode() + ? getSynapseContainerName() + : path.basename(synCfg.configDir)); + console.log( + `Starting synapse with config dir ${synCfg.configDir} in container ${containerName}...`, ); - } - const synapseId = await dockerRun({ - image: 'matrixdotorg/synapse:v1.126.0', - containerName, - dockerParams, - applicationParams: ['run'], - runAsUser: true, - }); + let dockerParams: string[] = [ + '--rm', + '-v', + `${synCfg.configDir}:/data`, + '-v', + `${path.join(__dirname, 'templates')}:/custom/templates/`, + ]; + if (useDynamicHostPort) { + // In dynamic-host-port mode multiple harnesses may run concurrently, so + // we must not claim the shared fixed Synapse container IP. + dockerParams.push('-p', `${hostPort}:8008/tcp`, '--network=boxel'); + } else { + dockerParams.push( + `--ip=${synCfg.host}`, + '-p', + `${synCfg.port}:8008/tcp`, + '--network=boxel', + ); + } + + try { + synapseId = await dockerRun({ + image: 'matrixdotorg/synapse:v1.126.0', + containerName, + dockerParams, + applicationParams: ['run'], + runAsUser: true, + }); + break; + } catch (error) { + if ( + !useDynamicHostPort || + !isPortBindError(error) || + attempt === attempts + ) { + throw error; + } + console.warn( + `Synapse host port ${hostPort} was claimed before Docker bound it; retrying (${attempt}/${attempts})...`, + ); + if (!opts?.dataDir) { + await fse.remove(synCfg.configDir); + } + } + } console.log(`Started synapse with id ${synapseId} on port ${hostPort}`); diff --git a/packages/realm-server/tests/index.ts b/packages/realm-server/tests/index.ts index d4b8302424..61e5f187df 100644 --- a/packages/realm-server/tests/index.ts +++ b/packages/realm-server/tests/index.ts @@ -158,6 +158,7 @@ import './server-endpoints/queue-status-test'; import './server-endpoints/realm-lifecycle-test'; import './server-endpoints/search-test'; import './server-endpoints/search-prerendered-test'; +import './server-config-test'; import './server-endpoints/info-test'; import './server-endpoints/stripe-session-test'; import './server-endpoints/stripe-webhook-test'; diff --git a/packages/realm-server/tests/server-config-test.ts b/packages/realm-server/tests/server-config-test.ts new file mode 100644 index 0000000000..81ce96e989 --- /dev/null +++ b/packages/realm-server/tests/server-config-test.ts @@ -0,0 +1,65 @@ +import { module, test } from 'qunit'; +import { basename } from 'path'; +import { dirSync } from 'tmp'; + +import { RealmServer } from '../server'; + +module(basename(__filename), function () { + test('prefers MATRIX_SERVER_NAME over matrix URL hostname in host config', async function (assert) { + let originalMatrixServerName = process.env.MATRIX_SERVER_NAME; + let tempDir = dirSync({ unsafeCleanup: true }); + + process.env.MATRIX_SERVER_NAME = 'stack.cards'; + + try { + let server = new RealmServer({ + serverURL: new URL('http://127.0.0.1:4448'), + realms: [], + virtualNetwork: {} as any, + matrixClient: { + matrixURL: new URL('http://localhost:8008/'), + } as any, + realmServerSecretSeed: 'test-realm-server-secret', + realmSecretSeed: 'test-realm-secret', + grafanaSecret: 'test-grafana-secret', + realmsRootPath: tempDir.name, + dbAdapter: {} as any, + queue: {} as any, + definitionLookup: {} as any, + assetsURL: new URL('http://example.com/notional-assets-host/'), + matrixRegistrationSecret: 'test-matrix-registration-secret', + getIndexHTML: async () => + ``, + }); + + let html = await (server as any).retrieveIndexHTML(); + let match = html.match( + //, + ); + + assert.ok(match, 'host config environment meta tag is present'); + + let config = JSON.parse(decodeURIComponent(match![1])); + assert.strictEqual( + config.matrixServerName, + 'stack.cards', + 'uses MATRIX_SERVER_NAME override in host config', + ); + } finally { + if (originalMatrixServerName == null) { + delete process.env.MATRIX_SERVER_NAME; + } else { + process.env.MATRIX_SERVER_NAME = originalMatrixServerName; + } + tempDir.removeCallback(); + } + }); +}); diff --git a/packages/software-factory/src/harness/shared.ts b/packages/software-factory/src/harness/shared.ts index ffe03999a5..2b8034c442 100644 --- a/packages/software-factory/src/harness/shared.ts +++ b/packages/software-factory/src/harness/shared.ts @@ -781,36 +781,53 @@ export async function stopManagedProcess(proc: SpawnedProcess): Promise { if (proc.exitCode !== null) { return; } - let stopped = new Promise((resolve) => { + let stopped = new Promise<'stopped'>((resolve) => { let onMessage = (message: unknown) => { if (message === 'stopped') { proc.off('message', onMessage); - resolve(true); + resolve('stopped'); } }; proc.on('message', onMessage); }); - let exited = new Promise((resolve) => { + let exited = new Promise<'exited'>((resolve) => { let onExit = () => { proc.off('exit', onExit); proc.off('error', onExit); - resolve(); + resolve('exited'); }; proc.on('exit', onExit); proc.on('error', onExit); }); proc.send('stop'); - let stoppedGracefully = await Promise.race([ + let stopResult = await Promise.race([ stopped, + exited, new Promise((resolve) => setTimeout(() => resolve(false), 15_000)), ]); - if (!stoppedGracefully && proc.exitCode === null) { + if (stopResult === false && proc.exitCode === null) { proc.send('kill'); } if (proc.exitCode === null) { - await Promise.race([ + let exitResult = await Promise.race([ exited, - new Promise((resolve) => setTimeout(resolve, 15_000)), + new Promise((resolve) => setTimeout(() => resolve(false), 15_000)), ]); + if (exitResult === false && proc.exitCode === null) { + try { + proc.kill(); + } catch { + // best effort hard-kill + } + exitResult = await Promise.race([ + exited, + new Promise((resolve) => + setTimeout(() => resolve(false), 5_000), + ), + ]); + if (exitResult === false && proc.exitCode === null) { + throw new Error('Failed to stop managed process within timeout'); + } + } } } From 65fe8c2ae062b5a51cbc538df625390c5efb7445 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 26 Mar 2026 17:18:02 -0400 Subject: [PATCH 7/7] Fix realm server lint shard list --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e0ff051a08..5d50ed8952 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -535,6 +535,7 @@ jobs: "server-endpoints/search-test.ts", "server-endpoints/info-test.ts", "server-endpoints/search-prerendered-test.ts", + "server-config-test.ts", "server-endpoints/stripe-session-test.ts", "server-endpoints/stripe-webhook-test.ts", "server-endpoints/user-and-catalog-test.ts",