Compare commits

...

5 Commits

Author SHA1 Message Date
Matt Hill
dc857e028f fix: address whitespace in URL display and blank tab on Open UI
Remove extra whitespace between protocol/hostname/port in address item
template. Handle missing access types (domain, tor, wan-ipv4) in
launchableAddress so Open UI resolves the correct URL. Disable the
button when no launchable address exists instead of opening a blank tab.
Fix mock startPackage never reaching running state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:44:24 -06:00
Aiden McClelland
c03bf166f1 Merge pull request #3158 from Start9Labs/fix/rpc-listener
fix: buffer incomplete chunks in RPC socket listener
2026-04-03 09:11:09 -06:00
Aiden McClelland
9373f40e82 chore: bump version to 0.4.0-beta.2 2026-04-03 09:06:05 -06:00
Aiden McClelland
f181b9a9f1 fix: handle None case in list_service_interfaces without early return 2026-04-03 09:00:20 -06:00
Matt Hill
2ea2879317 fix: buffer incomplete chunks in RPC socket listener
The data event handler assumed each chunk contained complete
newline-delimited JSON messages. Unix sockets are stream-based, so
large messages (>64KB) arrive split across multiple data events.
This caused JSON parse failures for callback payloads containing
full serviceInterfaces maps with hostname/gateway metadata.

Buffer incoming data per connection and only parse after a complete
newline-terminated message is received.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 08:30:52 -06:00
13 changed files with 130 additions and 55 deletions

View File

@@ -156,6 +156,7 @@ export class RpcListener {
this.unixSocketServer.on("connection", (s) => {
let id: IdType = null
let lineBuffer = ""
const captureId = <X>(x: X) => {
if (hasId(x)) id = x.id
return x
@@ -190,32 +191,31 @@ export class RpcListener {
)
}
}
s.on("data", (a) =>
Promise.resolve(a)
.then((b) => b.toString())
.then((buf) => {
for (let s of buf.split("\n")) {
if (s)
Promise.resolve(s)
.then(logData("dataIn"))
.then(jsonParse)
.then(captureId)
.then((x) => this.dealWithInput(x))
.catch(mapError)
.then(logData("response"))
.then(writeDataToSocket)
.then((_) => {
if (this.shouldExit) {
process.exit(0)
}
})
.catch((e) => {
console.error(`Major error in socket handling: ${e}`)
console.debug(`Data in: ${a.toString()}`)
})
}
}),
)
s.on("data", (a) => {
lineBuffer += a.toString()
const lines = lineBuffer.split("\n")
lineBuffer = lines.pop()! // keep incomplete trailing chunk in buffer
for (const line of lines) {
if (line)
Promise.resolve(line)
.then(logData("dataIn"))
.then(jsonParse)
.then(captureId)
.then((x) => this.dealWithInput(x))
.catch(mapError)
.then(logData("response"))
.then(writeDataToSocket)
.then((_) => {
if (this.shouldExit) {
process.exit(0)
}
})
.catch((e) => {
console.error(`Major error in socket handling: ${e}`)
console.debug(`Data in: ${line}`)
})
}
})
})
}

2
core/Cargo.lock generated
View File

@@ -6476,7 +6476,7 @@ dependencies = [
[[package]]
name = "start-os"
version = "0.4.0-beta.1"
version = "0.4.0-beta.2"
dependencies = [
"aes",
"async-acme",

View File

@@ -15,7 +15,7 @@ license = "MIT"
name = "start-os"
readme = "README.md"
repository = "https://github.com/Start9Labs/start-os"
version = "0.4.0-beta.1" # VERSION_BUMP
version = "0.4.0-beta.2" # VERSION_BUMP
[lib]
name = "startos"

View File

@@ -134,20 +134,18 @@ pub async fn list_service_interfaces(
.expect("valid json pointer");
let mut watch = context.seed.ctx.db.watch(ptr).await;
let Some(res) = from_value(watch.peek_and_mark_seen()?)? else {
return Ok(BTreeMap::new());
};
let res: Option<_> = from_value(watch.peek_and_mark_seen()?)?;
if let Some(callback) = callback {
let callback = callback.register(&context.seed.persistent_container);
context.seed.ctx.callbacks.add_list_service_interfaces(
package_id.clone(),
watch.typed::<BTreeMap<ServiceInterfaceId, ServiceInterface>>(),
watch.typed::<Option<BTreeMap<ServiceInterfaceId, ServiceInterface>>>(),
CallbackHandler::new(&context, callback),
);
}
Ok(res)
Ok(res.unwrap_or_default())
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]

View File

@@ -65,8 +65,9 @@ mod v0_4_0_alpha_22;
mod v0_4_0_alpha_23;
mod v0_4_0_beta_0;
mod v0_4_0_beta_1;
mod v0_4_0_beta_2;
pub type Current = v0_4_0_beta_1::Version; // VERSION_BUMP
pub type Current = v0_4_0_beta_2::Version; // VERSION_BUMP
impl Current {
#[instrument(skip(self, db))]
@@ -199,7 +200,8 @@ enum Version {
V0_4_0_alpha_22(Wrapper<v0_4_0_alpha_22::Version>),
V0_4_0_alpha_23(Wrapper<v0_4_0_alpha_23::Version>),
V0_4_0_beta_0(Wrapper<v0_4_0_beta_0::Version>),
V0_4_0_beta_1(Wrapper<v0_4_0_beta_1::Version>), // VERSION_BUMP
V0_4_0_beta_1(Wrapper<v0_4_0_beta_1::Version>),
V0_4_0_beta_2(Wrapper<v0_4_0_beta_2::Version>), // VERSION_BUMP
Other(exver::Version),
}
@@ -267,7 +269,8 @@ impl Version {
Self::V0_4_0_alpha_22(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_23(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_beta_0(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_beta_1(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP
Self::V0_4_0_beta_1(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_beta_2(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP
Self::Other(v) => {
return Err(Error::new(
eyre!("unknown version {v}"),
@@ -327,7 +330,8 @@ impl Version {
Version::V0_4_0_alpha_22(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_23(Wrapper(x)) => x.semver(),
Version::V0_4_0_beta_0(Wrapper(x)) => x.semver(),
Version::V0_4_0_beta_1(Wrapper(x)) => x.semver(), // VERSION_BUMP
Version::V0_4_0_beta_1(Wrapper(x)) => x.semver(),
Version::V0_4_0_beta_2(Wrapper(x)) => x.semver(), // VERSION_BUMP
Version::Other(x) => x.clone(),
}
}

View File

@@ -0,0 +1,37 @@
use exver::{PreReleaseSegment, VersionRange};
use super::v0_3_5::V0_3_0_COMPAT;
use super::{VersionT, v0_4_0_beta_1};
use crate::prelude::*;
lazy_static::lazy_static! {
static ref V0_4_0_beta_2: exver::Version = exver::Version::new(
[0, 4, 0],
[PreReleaseSegment::String("beta".into()), 2.into()]
);
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Version;
impl VersionT for Version {
type Previous = v0_4_0_beta_1::Version;
type PreUpRes = ();
async fn pre_up(self) -> Result<Self::PreUpRes, Error> {
Ok(())
}
fn semver(self) -> exver::Version {
V0_4_0_beta_2.clone()
}
fn compat(self) -> &'static VersionRange {
&V0_3_0_COMPAT
}
#[instrument(skip_all)]
fn up(self, _db: &mut Value, _: Self::PreUpRes) -> Result<Value, Error> {
Ok(Value::Null)
}
fn down(self, _db: &mut Value) -> Result<(), Error> {
Ok(())
}
}

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "startos-ui",
"version": "0.4.0-beta.1",
"version": "0.4.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "startos-ui",
"version": "0.4.0-beta.1",
"version": "0.4.0-beta.2",
"license": "MIT",
"dependencies": {
"@angular/cdk": "^21.2.1",

View File

@@ -1,6 +1,6 @@
{
"name": "startos-ui",
"version": "0.4.0-beta.1",
"version": "0.4.0-beta.2",
"author": "Start9 Labs, Inc",
"homepage": "https://start9.com/",
"license": "MIT",

View File

@@ -77,10 +77,8 @@ import { DomainHealthService } from './domain-health.service'
<span>{{ address.url | tuiObfuscate: 'mask' }}</span>
} @else {
<span [title]="address.url">
@if (urlParts(); as parts) {
{{ parts.prefix }}
<b>{{ parts.hostname }}</b>
{{ parts.suffix }}
@if (urlHtml(); as html) {
<span [innerHTML]="html"></span>
} @else {
{{ address.url }}
}
@@ -246,15 +244,14 @@ export class InterfaceAddressItemComponent {
this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none',
)
readonly urlParts = computed(() => {
readonly urlHtml = computed(() => {
const { url, hostnameInfo } = this.address()
const idx = url.indexOf(hostnameInfo.hostname)
if (idx === -1) return null
return {
prefix: url.slice(0, idx),
hostname: hostnameInfo.hostname,
suffix: url.slice(idx + hostnameInfo.hostname.length),
}
const prefix = url.slice(0, idx)
const hostname = hostnameInfo.hostname
const suffix = url.slice(idx + hostname.length)
return `${prefix}<b>${hostname}</b>${suffix}`
})
typeAppearance(kind: string): string {

View File

@@ -291,11 +291,25 @@ export class InterfaceService {
matching = mdns.format('urlstring')[0]
onLan = true
break
case 'domain':
matching = publicDomains.format('urlstring')[0]
break
case 'tor':
matching = addresses
.filter({
pluginId: 'tor',
})
.format('urlstring')[0]
break
case 'wan-ipv4':
matching = wanIp.format('urlstring')[0]
break
}
if (matching) return matching
if (onLan && bestPrivate) return bestPrivate
if (bestPublic) return bestPublic
if (bestPrivate) return bestPrivate
return ''
}
}

View File

@@ -49,6 +49,7 @@ import { InterfaceService } from '../../../components/interfaces/interface.servi
tuiChevron
tuiDropdownAuto
tuiDropdownLimitWidth="fixed"
[disabled]="!hasAnyHref()"
[tuiDropdown]="content"
>
{{ 'Open UI' | i18n }}
@@ -62,6 +63,7 @@ import { InterfaceService } from '../../../components/interfaces/interface.servi
rel="noreferrer"
iconEnd="@tui.external-link"
[attr.href]="getHref(i)"
[class.disabled]="!getHref(i)"
(click)="close()"
>
{{ i.name }}
@@ -69,12 +71,13 @@ import { InterfaceService } from '../../../components/interfaces/interface.servi
}
</tui-data-list>
</ng-template>
} @else if (interfaces()[0]) {
} @else if (interfaces()[0]; as ui) {
<button
tuiButton
appearance="primary-grayscale"
iconStart="@tui.external-link"
(click)="openUI(interfaces()[0]!)"
[disabled]="!getHref(ui)"
(click)="openUI(ui)"
>
{{ 'Open UI' | i18n }}
</button>
@@ -163,6 +166,10 @@ export class ServiceControlsComponent {
),
)
readonly hasAnyHref = computed(() =>
this.interfaces().some(i => !!this.getHref(i)),
)
getHref(ui: T.ServiceInterface): string {
const host = this.pkg().hosts[ui.addressInfo.hostId]
if (!host) return ''
@@ -170,6 +177,9 @@ export class ServiceControlsComponent {
}
openUI(ui: T.ServiceInterface) {
this.document.defaultView?.open(this.getHref(ui), '_blank', 'noreferrer')
const href = this.getHref(ui)
if (href) {
this.document.defaultView?.open(href, '_blank', 'noreferrer')
}
}
}

View File

@@ -1224,8 +1224,8 @@ export class MockApiService extends ApiService {
},
'p2p-interface': {
name: 'P2P Interface',
result: 'waiting',
message: 'Chain State',
result: 'success',
message: null,
},
'rpc-interface': {
name: 'RPC Interface',

View File

@@ -497,6 +497,21 @@ export const mockPatchData: DataModel = {
suffix: '',
},
},
'admin-ui': {
id: 'admin-ui',
masked: false,
name: 'Admin UI',
description: 'An admin panel for managing your Bitcoin node',
type: 'ui',
addressInfo: {
username: null,
hostId: 'abcdefg',
internalPort: 80,
scheme: 'http',
sslScheme: 'https',
suffix: '/admin',
},
},
rpc: {
id: 'rpc',
masked: true,