| author | |
| committer | |
| log | d9c1732b2791f762bce06e4898cdb6679ce24961 |
| tree | d37ae9fb1b6f84dde7ad265d069de91c8ba6a697 |
| parent | 49c2b4471c59b21759eb2e254e9c4fa2579888a3 |
| signature | Signed by PGP key B5690EEEBB952194 |
2 files changed, 54 insertions(+), 10 deletions(-)
packages/tailscale/src/tailscale.test.ts+39| ... | @@ -212,6 +212,45 @@ describe("tailscale", () => { | ... | @@ -212,6 +212,45 @@ describe("tailscale", () => { |
| 212 | }); | 212 | }); |
| 213 | }); | 213 | }); |
| 214 | 214 | ||
| 215 | it.effect("turns spawn defects into typed spawn failures", () => { | ||
| 216 | // A non-directory entry on PATH makes node's spawn throw ENOTDIR | ||
| 217 | // synchronously. The platform spawner calls `NodeChildProcess.spawn` from | ||
| 218 | // inside an `Effect.callback` registration, so that throw arrives as a | ||
| 219 | // defect rather than a typed error - the shape reproduced here. | ||
| 220 | const defect = Object.assign(new Error("spawn tailscale ENOTDIR"), { code: "ENOTDIR" }); | ||
| 221 | const layer = Layer.succeed( | ||
| 222 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 223 | ChildProcessSpawner.make(() => | ||
| 224 | Effect.callback<never, never>(() => { | ||
| 225 | throw defect; | ||
| 226 | }), | ||
| 227 | ), | ||
| 228 | ); | ||
| 229 | |||
| 230 | return Effect.gen(function* () { | ||
| 231 | const statusError = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); | ||
| 232 | assert.instanceOf(statusError, TailscaleCommandSpawnError); | ||
| 233 | assert.equal(statusError.subcommand, "status"); | ||
| 234 | assert.strictEqual(statusError.cause, defect); | ||
| 235 | |||
| 236 | const serveError = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( | ||
| 237 | Effect.flip, | ||
| 238 | Effect.provide(layer), | ||
| 239 | ); | ||
| 240 | assert.instanceOf(serveError, TailscaleCommandSpawnError); | ||
| 241 | assert.equal(serveError.subcommand, "serve"); | ||
| 242 | assert.strictEqual(serveError.cause, defect); | ||
| 243 | |||
| 244 | // What callers actually rely on: the desktop endpoint providers recover | ||
| 245 | // with `Effect.orElseSucceed`, which only sees the typed error channel. | ||
| 246 | const degraded = yield* readTailscaleStatus.pipe( | ||
| 247 | Effect.orElseSucceed(() => null), | ||
| 248 | Effect.provide(layer), | ||
| 249 | ); | ||
| 250 | assert.equal(degraded, null); | ||
| 251 | }); | ||
| 252 | }); | ||
| 253 | |||
| 215 | it.effect("keeps nonzero exit diagnostics structured", () => { | 254 | it.effect("keeps nonzero exit diagnostics structured", () => { |
| 216 | const layer = mockSpawnerLayer(() => ({ | 255 | const layer = mockSpawnerLayer(() => ({ |
| 217 | code: 7, | 256 | code: 7, |
packages/tailscale/src/tailscale.ts+15-10| ... | @@ -228,11 +228,15 @@ export const readTailscaleStatus = Effect.gen(function* () { | ... | @@ -228,11 +228,15 @@ export const readTailscaleStatus = Effect.gen(function* () { |
| 228 | argumentCount: args.length, | 228 | argumentCount: args.length, |
| 229 | }; | 229 | }; |
| 230 | return yield* Effect.gen(function* () { | 230 | return yield* Effect.gen(function* () { |
| 231 | const child = yield* spawner | 231 | const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( |
| 232 | .spawn(ChildProcess.make(executable, args)) | 232 | Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), |
| 233 | .pipe( | 233 | // Spawning can also fail as a defect rather than a typed error - a |
| 234 | Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), | 234 | // non-directory entry on PATH makes node throw ENOTDIR synchronously. |
| 235 | ); | 235 | // `mapError` never sees that, so it would escape as an uncaught error. |
| 236 | Effect.catchDefect((cause) => | ||
| 237 | Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), | ||
| 238 | ), | ||
| 239 | ); | ||
| 236 | const [stdout, stderr, exitCode] = yield* Effect.all( | 240 | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 237 | [ | 241 | [ |
| 238 | collectStdout(child.stdout), | 242 | collectStdout(child.stdout), |
| ... | @@ -299,11 +303,12 @@ const runTailscaleCommand = ( | ... | @@ -299,11 +303,12 @@ const runTailscaleCommand = ( |
| 299 | }; | 303 | }; |
| 300 | const timeout = Duration.fromInputUnsafe(timeoutInput); | 304 | const timeout = Duration.fromInputUnsafe(timeoutInput); |
| 301 | return yield* Effect.gen(function* () { | 305 | return yield* Effect.gen(function* () { |
| 302 | const child = yield* spawner | 306 | const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( |
| 303 | .spawn(ChildProcess.make(executable, args)) | 307 | Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), |
| 304 | .pipe( | 308 | Effect.catchDefect((cause) => |
| 305 | Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), | 309 | Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), |
| 306 | ); | 310 | ), |
| 311 | ); | ||
| 307 | const [stderr, exitCode] = yield* Effect.all( | 312 | const [stderr, exitCode] = yield* Effect.all( |
| 308 | [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], | 313 | [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], |
| 309 | { concurrency: "unbounded" }, | 314 | { concurrency: "unbounded" }, |