Skip to content

Commit b25750d

Browse files
authored
Merge pull request #235 from tobexyz/issue232
Issue232
2 parents 9ffcf15 + 2df5dfc commit b25750d

4 files changed

Lines changed: 136 additions & 11 deletions

File tree

yaacc/src/main/java/de/yaacc/upnp/protocol/async/RetrieveRemoteDescriptors.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,16 @@ protected void describe() throws IOException {
163163
+ rd.getIdentity().getDescriptorURL()
164164
+ " ,exception on send: ", ex);
165165
return;
166+
} catch (RuntimeException ex) {
167+
// Defence in depth: a single misbehaving device on the LAN must never be allowed
168+
// to kill the discovery worker thread. The cling library has multiple throw sites
169+
// for runtime exceptions (e.g. IllegalStateException for unmapped HTTP statuses,
170+
// see issue #232 / PR #221 #219) - swallow them all at the worker boundary.
171+
YaaccLogger.w(getClass().getName(),
172+
"Device descriptor retrieval failed: "
173+
+ rd.getIdentity().getDescriptorURL()
174+
+ " ,unexpected runtime exception: ", ex);
175+
return;
166176
}
167177

168178

yaacc/src/main/java/de/yaacc/upnp/server/http/HttpRequestSender.java

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@
4949
import java.nio.charset.Charset;
5050
import java.util.List;
5151
import java.util.Map;
52-
import java.util.Objects;
5352
import java.util.concurrent.TimeUnit;
5453

5554
import de.yaacc.util.YaaccLogger;
@@ -127,16 +126,23 @@ private void applyRequestBody(StreamRequestMessage requestMessage, ClassicHttpRe
127126

128127

129128
protected StreamResponseMessage createResponse(ClassicHttpResponse response) throws IOException {
130-
// Status
131-
if (UpnpResponse.Status.getByStatusCode(response.getCode()) == null) {
132-
throw new IllegalStateException("can't create UpnpResponse.Status from http response status: " + response.getCode());
133-
}
134129
YaaccLogger.d(getClass().getName(), "Received response code: " + response.getCode());
135-
UpnpResponse responseOperation =
136-
new UpnpResponse(
137-
response.getCode(),
138-
Objects.requireNonNull(UpnpResponse.Status.getByStatusCode(response.getCode())).getStatusMsg()
139-
);
130+
UpnpResponse.Status mappedStatus = UpnpResponse.Status.getByStatusCode(response.getCode());
131+
UpnpResponse responseOperation;
132+
if (mappedStatus != null) {
133+
responseOperation = new UpnpResponse(response.getCode(), mappedStatus.getStatusMsg());
134+
} else {
135+
// Some real-world UPnP devices return HTTP statuses outside the small canonical
136+
// UpnpResponse.Status enum (e.g. Sky Q DVRs return 401 on description fetches by
137+
// design - see issue #232). Synthesize a generic failed UpnpResponse instead of
138+
// throwing - the caller path (RetrieveRemoteDescriptors.describe) already treats
139+
// isFailed() responses as a per-device skip, so unmapped statuses flow through
140+
// the same skip path.
141+
YaaccLogger.w(getClass().getName(),
142+
"Unmapped HTTP status " + response.getCode()
143+
+ " from upstream UPnP device - treating as non-UPnP failure response, skipping device");
144+
responseOperation = new UpnpResponse(response.getCode(), "HTTP " + response.getCode());
145+
}
140146
YaaccLogger.d(getClass().getName(), "Received response: " + responseOperation);
141147
StreamResponseMessage responseMessage = new StreamResponseMessage(responseOperation);
142148
// Headers

yaacc/src/main/java/org/fourthline/cling/model/message/UpnpResponse.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ public static enum Status {
2626

2727
OK(200, "OK"),
2828
BAD_REQUEST(400, "Bad Request"),
29+
UNAUTHORIZED(401, "Unauthorized"),
30+
FORBIDDEN(403, "Forbidden"),
2931
NOT_FOUND(404, "Not Found"),
3032
METHOD_NOT_SUPPORTED(405, "Method Not Supported"),
3133
PRECONDITION_FAILED(412, "Precondition Failed"),

yaacc/src/test/java/de/yaacc/upnp/server/http/HttpRequestSenderTest.java

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,35 @@
1+
/*
2+
*
3+
* Copyright (C) 2026 Tobias Schoene www.yaacc.de
4+
*
5+
* This program is free software; you can redistribute it and/or
6+
* modify it under the terms of the GNU General Public License
7+
* as published by the Free Software Foundation; either version 3
8+
* of the License, or (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program; if not, write to the Free Software
17+
* Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
18+
*/
119
package de.yaacc.upnp.server.http;
220

21+
import static org.junit.Assert.assertEquals;
22+
import static org.junit.Assert.assertFalse;
23+
import static org.junit.Assert.assertNotNull;
24+
import static org.junit.Assert.assertTrue;
25+
26+
import org.apache.hc.core5.http.ContentType;
27+
import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
28+
import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
29+
import org.fourthline.cling.model.message.StreamResponseMessage;
330
import org.junit.Test;
431

5-
import static org.junit.Assert.*;
32+
import java.nio.charset.StandardCharsets;
633

734
public class HttpRequestSenderTest {
835

@@ -18,4 +45,84 @@ public void testCreateRequestMessage() {
1845
HttpRequestSender httpRequestSender = new HttpRequestSender();
1946
assertNotNull(httpRequestSender);
2047
}
48+
49+
/**
50+
* Happy path: an HTTP 200 response is mapped to a successful UpnpResponse
51+
* with the canonical "OK" status message.
52+
*/
53+
@Test
54+
public void testCreateResponse_200_mapsToOk() throws Exception {
55+
StreamResponseMessage msg = new HttpRequestSender().createResponse(buildResponse(200, "<root/>"));
56+
assertNotNull(msg);
57+
assertEquals(200, msg.getOperation().getStatusCode());
58+
assertEquals("OK", msg.getOperation().getStatusMessage());
59+
assertFalse(msg.getOperation().isFailed());
60+
}
61+
62+
/**
63+
* Regression for issue #232. A 401 (e.g. Sky Q DVR's authenticated UPnP
64+
* service) used to throw IllegalStateException out of createResponse and
65+
* crash the discovery worker. It must now produce a synthetic failed
66+
* UpnpResponse so the caller can skip the device.
67+
*/
68+
@Test
69+
public void testCreateResponse_401_doesNotThrow() throws Exception {
70+
StreamResponseMessage msg = new HttpRequestSender().createResponse(buildResponse(401, "Unauthorized"));
71+
assertNotNull(msg);
72+
assertEquals(401, msg.getOperation().getStatusCode());
73+
assertEquals("Unauthorized", msg.getOperation().getStatusMessage());
74+
assertTrue("401 must report isFailed() so the discovery loop skips the device",
75+
msg.getOperation().isFailed());
76+
}
77+
78+
/**
79+
* Regression for issue #232. A 403 produce a synthetic failed
80+
* UpnpResponse so the caller can skip the device.
81+
*/
82+
@Test
83+
public void testCreateResponse_403_doesNotThrow() throws Exception {
84+
StreamResponseMessage msg = new HttpRequestSender().createResponse(buildResponse(403, "Forbidden"));
85+
assertNotNull(msg);
86+
assertEquals(403, msg.getOperation().getStatusCode());
87+
assertEquals("Forbidden", msg.getOperation().getStatusMessage());
88+
assertTrue("403 must report isFailed() so the discovery loop skips the device",
89+
msg.getOperation().isFailed());
90+
}
91+
92+
/**
93+
* Regression for issue #219 / PR #221. 503 was added to the canonical
94+
* Status enum in that PR; verify it still maps to the canonical
95+
* "Service Unavailable" message rather than the synthetic fallback.
96+
*/
97+
@Test
98+
public void testCreateResponse_503_mapsToServiceUnavailable() throws Exception {
99+
StreamResponseMessage msg = new HttpRequestSender().createResponse(buildResponse(503, ""));
100+
assertNotNull(msg);
101+
assertEquals(503, msg.getOperation().getStatusCode());
102+
assertEquals("Service Unavailable", msg.getOperation().getStatusMessage());
103+
assertTrue(msg.getOperation().isFailed());
104+
}
105+
106+
/**
107+
* Any HTTP status not in UpnpResponse.Status (here: a deliberately
108+
* fictitious 418) must not throw and must produce a synthetic failed
109+
* response. This guards against future status codes the canonical enum
110+
* does not yet know about (issue #232 explicitly argues against the
111+
* whack-a-mole alternative of expanding the enum one code at a time).
112+
*/
113+
@Test
114+
public void testCreateResponse_418_unmappedDoesNotThrow() throws Exception {
115+
StreamResponseMessage msg = new HttpRequestSender().createResponse(buildResponse(418, "I'm a teapot"));
116+
assertNotNull(msg);
117+
assertEquals(418, msg.getOperation().getStatusCode());
118+
assertEquals("HTTP 418", msg.getOperation().getStatusMessage());
119+
assertTrue(msg.getOperation().isFailed());
120+
}
121+
122+
private static BasicClassicHttpResponse buildResponse(int statusCode, String body) {
123+
BasicClassicHttpResponse response = new BasicClassicHttpResponse(statusCode);
124+
byte[] bytes = body == null ? new byte[0] : body.getBytes(StandardCharsets.UTF_8);
125+
response.setEntity(new ByteArrayEntity(bytes, ContentType.TEXT_PLAIN));
126+
return response;
127+
}
21128
}

0 commit comments

Comments
 (0)